@shipfox/client-agent 0.2.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.
Files changed (182) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +4 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +93 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/add-custom-provider-card.d.ts +4 -0
  10. package/dist/components/add-custom-provider-card.d.ts.map +1 -0
  11. package/dist/components/add-custom-provider-card.js +41 -0
  12. package/dist/components/add-custom-provider-card.js.map +1 -0
  13. package/dist/components/agent-workflow-example.d.ts +11 -0
  14. package/dist/components/agent-workflow-example.d.ts.map +1 -0
  15. package/dist/components/agent-workflow-example.js +38 -0
  16. package/dist/components/agent-workflow-example.js.map +1 -0
  17. package/dist/components/available-provider-card.d.ts +6 -0
  18. package/dist/components/available-provider-card.d.ts.map +1 -0
  19. package/dist/components/available-provider-card.js +41 -0
  20. package/dist/components/available-provider-card.js.map +1 -0
  21. package/dist/components/available-providers-grid.d.ts +10 -0
  22. package/dist/components/available-providers-grid.d.ts.map +1 -0
  23. package/dist/components/available-providers-grid.js +89 -0
  24. package/dist/components/available-providers-grid.js.map +1 -0
  25. package/dist/components/change-default-model-form.d.ts +9 -0
  26. package/dist/components/change-default-model-form.d.ts.map +1 -0
  27. package/dist/components/change-default-model-form.js +98 -0
  28. package/dist/components/change-default-model-form.js.map +1 -0
  29. package/dist/components/custom-model-provider-api-options.d.ts +7 -0
  30. package/dist/components/custom-model-provider-api-options.d.ts.map +1 -0
  31. package/dist/components/custom-model-provider-api-options.js +23 -0
  32. package/dist/components/custom-model-provider-api-options.js.map +1 -0
  33. package/dist/components/custom-model-provider-form.d.ts +8 -0
  34. package/dist/components/custom-model-provider-form.d.ts.map +1 -0
  35. package/dist/components/custom-model-provider-form.js +667 -0
  36. package/dist/components/custom-model-provider-form.js.map +1 -0
  37. package/dist/components/custom-model-provider-form.stories.d.ts +20 -0
  38. package/dist/components/custom-model-provider-form.stories.d.ts.map +1 -0
  39. package/dist/components/custom-model-provider-form.stories.js +104 -0
  40. package/dist/components/custom-model-provider-form.stories.js.map +1 -0
  41. package/dist/components/custom-model-provider-payload.d.ts +39 -0
  42. package/dist/components/custom-model-provider-payload.d.ts.map +1 -0
  43. package/dist/components/custom-model-provider-payload.js +203 -0
  44. package/dist/components/custom-model-provider-payload.js.map +1 -0
  45. package/dist/components/custom-model-provider-slug.d.ts +3 -0
  46. package/dist/components/custom-model-provider-slug.d.ts.map +1 -0
  47. package/dist/components/custom-model-provider-slug.js +14 -0
  48. package/dist/components/custom-model-provider-slug.js.map +1 -0
  49. package/dist/components/default-model-field.d.ts +16 -0
  50. package/dist/components/default-model-field.d.ts.map +1 -0
  51. package/dist/components/default-model-field.js +60 -0
  52. package/dist/components/default-model-field.js.map +1 -0
  53. package/dist/components/form-errors.d.ts +11 -0
  54. package/dist/components/form-errors.d.ts.map +1 -0
  55. package/dist/components/form-errors.js +101 -0
  56. package/dist/components/form-errors.js.map +1 -0
  57. package/dist/components/harness-availability.d.ts +8 -0
  58. package/dist/components/harness-availability.d.ts.map +1 -0
  59. package/dist/components/harness-availability.js +16 -0
  60. package/dist/components/harness-availability.js.map +1 -0
  61. package/dist/components/harnesses-section.d.ts +4 -0
  62. package/dist/components/harnesses-section.d.ts.map +1 -0
  63. package/dist/components/harnesses-section.js +249 -0
  64. package/dist/components/harnesses-section.js.map +1 -0
  65. package/dist/components/harnesses-section.stories.d.ts +25 -0
  66. package/dist/components/harnesses-section.stories.d.ts.map +1 -0
  67. package/dist/components/harnesses-section.stories.js +138 -0
  68. package/dist/components/harnesses-section.stories.js.map +1 -0
  69. package/dist/components/model-provider-usage-modal.d.ts +11 -0
  70. package/dist/components/model-provider-usage-modal.d.ts.map +1 -0
  71. package/dist/components/model-provider-usage-modal.js +349 -0
  72. package/dist/components/model-provider-usage-modal.js.map +1 -0
  73. package/dist/components/model-provider-usage-modal.stories.d.ts +21 -0
  74. package/dist/components/model-provider-usage-modal.stories.d.ts.map +1 -0
  75. package/dist/components/model-provider-usage-modal.stories.js +115 -0
  76. package/dist/components/model-provider-usage-modal.stories.js.map +1 -0
  77. package/dist/components/model-provider-usage-target.d.ts +14 -0
  78. package/dist/components/model-provider-usage-target.d.ts.map +1 -0
  79. package/dist/components/model-provider-usage-target.js +20 -0
  80. package/dist/components/model-provider-usage-target.js.map +1 -0
  81. package/dist/components/model-providers-section.d.ts +4 -0
  82. package/dist/components/model-providers-section.d.ts.map +1 -0
  83. package/dist/components/model-providers-section.js +668 -0
  84. package/dist/components/model-providers-section.js.map +1 -0
  85. package/dist/components/model-providers-section.stories.d.ts +32 -0
  86. package/dist/components/model-providers-section.stories.d.ts.map +1 -0
  87. package/dist/components/model-providers-section.stories.js +442 -0
  88. package/dist/components/model-providers-section.stories.js.map +1 -0
  89. package/dist/components/provider-search.d.ts +4 -0
  90. package/dist/components/provider-search.d.ts.map +1 -0
  91. package/dist/components/provider-search.js +13 -0
  92. package/dist/components/provider-search.js.map +1 -0
  93. package/dist/components/supported-model-provider-catalog-entry.d.ts +8 -0
  94. package/dist/components/supported-model-provider-catalog-entry.d.ts.map +1 -0
  95. package/dist/components/supported-model-provider-catalog-entry.js +9 -0
  96. package/dist/components/supported-model-provider-catalog-entry.js.map +1 -0
  97. package/dist/components/test-and-save-form.d.ts +10 -0
  98. package/dist/components/test-and-save-form.d.ts.map +1 -0
  99. package/dist/components/test-and-save-form.js +138 -0
  100. package/dist/components/test-and-save-form.js.map +1 -0
  101. package/dist/feature.d.ts +9 -0
  102. package/dist/feature.d.ts.map +1 -0
  103. package/dist/feature.js +13 -0
  104. package/dist/feature.js.map +1 -0
  105. package/dist/hooks/api/model-providers.d.ts +334 -0
  106. package/dist/hooks/api/model-providers.d.ts.map +1 -0
  107. package/dist/hooks/api/model-providers.js +190 -0
  108. package/dist/hooks/api/model-providers.js.map +1 -0
  109. package/dist/index.d.ts +9 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +10 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/pages/model-provider-onboarding-page.d.ts +6 -0
  114. package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -0
  115. package/dist/pages/model-provider-onboarding-page.js +295 -0
  116. package/dist/pages/model-provider-onboarding-page.js.map +1 -0
  117. package/dist/pages/model-provider-onboarding-page.stories.d.ts +27 -0
  118. package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -0
  119. package/dist/pages/model-provider-onboarding-page.stories.js +368 -0
  120. package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -0
  121. package/dist/routes/model-provider.d.ts +5 -0
  122. package/dist/routes/model-provider.d.ts.map +1 -0
  123. package/dist/routes/model-provider.js +28 -0
  124. package/dist/routes/model-provider.js.map +1 -0
  125. package/dist/state/model-provider-onboarding.d.ts +3 -0
  126. package/dist/state/model-provider-onboarding.d.ts.map +1 -0
  127. package/dist/state/model-provider-onboarding.js +28 -0
  128. package/dist/state/model-provider-onboarding.js.map +1 -0
  129. package/dist/tsconfig.test.tsbuildinfo +1 -0
  130. package/package.json +114 -0
  131. package/src/components/add-custom-provider-card.tsx +32 -0
  132. package/src/components/agent-workflow-example.test.ts +74 -0
  133. package/src/components/agent-workflow-example.ts +55 -0
  134. package/src/components/available-provider-card.tsx +39 -0
  135. package/src/components/available-providers-grid.test.tsx +55 -0
  136. package/src/components/available-providers-grid.tsx +106 -0
  137. package/src/components/change-default-model-form.tsx +104 -0
  138. package/src/components/custom-model-provider-api-options.test.ts +18 -0
  139. package/src/components/custom-model-provider-api-options.ts +12 -0
  140. package/src/components/custom-model-provider-form.stories.tsx +91 -0
  141. package/src/components/custom-model-provider-form.tsx +710 -0
  142. package/src/components/custom-model-provider-payload.test.ts +170 -0
  143. package/src/components/custom-model-provider-payload.ts +264 -0
  144. package/src/components/custom-model-provider-slug.test.ts +40 -0
  145. package/src/components/custom-model-provider-slug.ts +23 -0
  146. package/src/components/default-model-field.tsx +85 -0
  147. package/src/components/form-errors.test.ts +159 -0
  148. package/src/components/form-errors.ts +102 -0
  149. package/src/components/harness-availability.test.ts +45 -0
  150. package/src/components/harness-availability.ts +35 -0
  151. package/src/components/harnesses-section.stories.tsx +129 -0
  152. package/src/components/harnesses-section.test.tsx +188 -0
  153. package/src/components/harnesses-section.tsx +245 -0
  154. package/src/components/model-provider-usage-modal.stories.tsx +106 -0
  155. package/src/components/model-provider-usage-modal.test.tsx +210 -0
  156. package/src/components/model-provider-usage-modal.tsx +326 -0
  157. package/src/components/model-provider-usage-target.ts +36 -0
  158. package/src/components/model-providers-section.stories.tsx +345 -0
  159. package/src/components/model-providers-section.test.tsx +831 -0
  160. package/src/components/model-providers-section.tsx +645 -0
  161. package/src/components/provider-search.test.ts +45 -0
  162. package/src/components/provider-search.ts +18 -0
  163. package/src/components/supported-model-provider-catalog-entry.ts +19 -0
  164. package/src/components/test-and-save-form.tsx +166 -0
  165. package/src/feature.ts +12 -0
  166. package/src/hooks/api/model-providers.test.ts +167 -0
  167. package/src/hooks/api/model-providers.ts +281 -0
  168. package/src/index.ts +8 -0
  169. package/src/pages/model-provider-onboarding-page.stories.tsx +284 -0
  170. package/src/pages/model-provider-onboarding-page.test.tsx +368 -0
  171. package/src/pages/model-provider-onboarding-page.tsx +290 -0
  172. package/src/routes/model-provider.tsx +20 -0
  173. package/src/state/model-provider-onboarding.test.ts +55 -0
  174. package/src/state/model-provider-onboarding.ts +34 -0
  175. package/test/fixtures/model-providers.ts +111 -0
  176. package/test/setup.ts +3 -0
  177. package/tsconfig.build.json +9 -0
  178. package/tsconfig.build.tsbuildinfo +1 -0
  179. package/tsconfig.json +3 -0
  180. package/tsconfig.test.json +8 -0
  181. package/vercel.json +8 -0
  182. package/vitest.config.ts +72 -0
@@ -0,0 +1,290 @@
1
+ import {
2
+ DEFAULT_HARNESS,
3
+ type Harness,
4
+ type HarnessDescriptor,
5
+ harnessSupportsProvider,
6
+ listHarnessDescriptors,
7
+ type ModelProviderCatalogEntryDto,
8
+ } from '@shipfox/api-agent-dto';
9
+ import {QueryLoadError} from '@shipfox/client-ui';
10
+ import {Button} from '@shipfox/react-ui/button';
11
+ import {Callout} from '@shipfox/react-ui/callout';
12
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
13
+ import {Icon} from '@shipfox/react-ui/icon';
14
+ import {Modal, ModalContent, ModalHeader, ModalTitle} from '@shipfox/react-ui/modal';
15
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
16
+ import {toast} from '@shipfox/react-ui/toast';
17
+ import {Header, Text} from '@shipfox/react-ui/typography';
18
+ import {cn} from '@shipfox/react-ui/utils';
19
+ import {useEffect, useMemo, useState} from 'react';
20
+ import {AvailableProvidersGrid, PROVIDER_GRID_CLASS} from '#components/available-providers-grid.js';
21
+ import {modelProviderConfigErrorToFormError} from '#components/form-errors.js';
22
+ import {ModelProviderTestAndSaveForm} from '#components/test-and-save-form.js';
23
+ import {
24
+ useModelProviderCatalogQuery,
25
+ useSetDefaultHarnessMutation,
26
+ } from '#hooks/api/model-providers.js';
27
+ import {dismissModelProviderOnboarding} from '#state/model-provider-onboarding.js';
28
+
29
+ const SURFACE_CLASS =
30
+ 'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
31
+
32
+ export function ModelProviderOnboardingPage({
33
+ workspaceId,
34
+ onSkip,
35
+ onConfigured,
36
+ }: {
37
+ workspaceId: string;
38
+ onSkip: () => void;
39
+ onConfigured: () => void;
40
+ }) {
41
+ const catalogQuery = useModelProviderCatalogQuery();
42
+ const setDefaultHarness = useSetDefaultHarnessMutation();
43
+ const [selectedHarness, setSelectedHarness] = useState<Harness | null>(null);
44
+ const [selectedEntry, setSelectedEntry] = useState<ModelProviderCatalogEntryDto | null>(null);
45
+ const [defaultHarnessError, setDefaultHarnessError] = useState<string | undefined>();
46
+ const [isSettingDefaultHarness, setIsSettingDefaultHarness] = useState(false);
47
+ const supportedProviders =
48
+ catalogQuery.data?.providers.filter((provider) => provider.support_status === 'supported') ??
49
+ [];
50
+ const filteredProviders = useMemo(() => {
51
+ if (selectedHarness === null) return [];
52
+ return supportedProviders.filter((provider) =>
53
+ harnessSupportsProvider(selectedHarness, provider.id),
54
+ );
55
+ }, [selectedHarness, supportedProviders]);
56
+
57
+ useEffect(() => {
58
+ document
59
+ .getElementById(
60
+ selectedHarness === null ? 'model-provider-harness-step' : 'model-provider-provider-step',
61
+ )
62
+ ?.focus();
63
+ }, [selectedHarness]);
64
+
65
+ function handleSkip() {
66
+ try {
67
+ dismissModelProviderOnboarding(workspaceId);
68
+ } catch {
69
+ // Skip is best-effort persistence; access to the product must not depend on storage.
70
+ }
71
+ onSkip();
72
+ }
73
+
74
+ async function handleProviderSaved() {
75
+ if (selectedEntry === null || selectedHarness === null) return;
76
+
77
+ setDefaultHarnessError(undefined);
78
+ if (selectedHarness === DEFAULT_HARNESS) {
79
+ toast.success(`${selectedEntry.label} saved`);
80
+ onConfigured();
81
+ return;
82
+ }
83
+
84
+ setIsSettingDefaultHarness(true);
85
+ try {
86
+ await setDefaultHarness.mutateAsync({
87
+ workspaceId,
88
+ body: {harness_id: selectedHarness},
89
+ });
90
+ toast.success(`${selectedEntry.label} saved`);
91
+ onConfigured();
92
+ } catch (error) {
93
+ const mapped = modelProviderConfigErrorToFormError(error);
94
+ setDefaultHarnessError(mapped.message || 'Could not save default harness. Try again.');
95
+ } finally {
96
+ setIsSettingDefaultHarness(false);
97
+ }
98
+ }
99
+
100
+ const selectedHarnessDescriptor =
101
+ selectedHarness === null
102
+ ? null
103
+ : listHarnessDescriptors().find((harness) => harness.id === selectedHarness);
104
+ const headingId =
105
+ selectedHarness === null ? 'model-provider-harness-step' : 'model-provider-provider-step';
106
+
107
+ return (
108
+ <div className="mx-auto flex w-full max-w-[640px] flex-col gap-20">
109
+ <header className="flex flex-col gap-12">
110
+ <div className="flex items-start justify-between gap-16 max-[520px]:flex-col max-[520px]:gap-8">
111
+ <Header id={headingId} variant="h1" tabIndex={-1} className="outline-none">
112
+ {selectedHarnessDescriptor
113
+ ? `Configure ${selectedHarnessDescriptor.label}`
114
+ : 'Choose agent harness'}
115
+ </Header>
116
+ <Button type="button" variant="secondary" onClick={handleSkip} className="shrink-0">
117
+ Skip for now
118
+ </Button>
119
+ </div>
120
+ <Text size="md" className="text-foreground-neutral-muted">
121
+ {selectedHarnessDescriptor
122
+ ? `${selectedHarnessDescriptor.label} needs a compatible model provider. Add your own API key, or go back to choose a different harness.`
123
+ : 'A harness runs agent steps. Choose the engine you want first, then connect a compatible model provider.'}
124
+ </Text>
125
+ </header>
126
+
127
+ <section aria-labelledby={headingId} className="flex flex-col gap-16">
128
+ {selectedHarness === null ? (
129
+ <HarnessPicker onSelect={setSelectedHarness} />
130
+ ) : (
131
+ <>
132
+ <div>
133
+ <Button type="button" variant="transparent" onClick={() => setSelectedHarness(null)}>
134
+ Back
135
+ </Button>
136
+ </div>
137
+ <ModelProviderPicker
138
+ key={selectedHarness}
139
+ catalogQuery={catalogQuery}
140
+ supportedProviders={filteredProviders}
141
+ onSelect={(entry) => {
142
+ setDefaultHarnessError(undefined);
143
+ setSelectedEntry(entry);
144
+ }}
145
+ />
146
+ </>
147
+ )}
148
+ </section>
149
+
150
+ <Modal
151
+ open={selectedEntry !== null}
152
+ onOpenChange={(open) => {
153
+ if (!open && !isSettingDefaultHarness) {
154
+ setSelectedEntry(null);
155
+ setDefaultHarnessError(undefined);
156
+ }
157
+ }}
158
+ >
159
+ <ModalContent aria-describedby={undefined}>
160
+ <ModalTitle className="sr-only">
161
+ {selectedEntry ? `Configure ${selectedEntry.label}` : ''}
162
+ </ModalTitle>
163
+ <ModalHeader>
164
+ <Text
165
+ size="lg"
166
+ aria-hidden="true"
167
+ className="overflow-ellipsis overflow-hidden whitespace-nowrap"
168
+ >
169
+ {selectedEntry ? `Configure ${selectedEntry.label}` : ''}
170
+ </Text>
171
+ </ModalHeader>
172
+ {isSettingDefaultHarness ? (
173
+ <div role="status" className="px-20 pb-8">
174
+ <Text size="sm" className="text-foreground-neutral-muted">
175
+ Saving harness default...
176
+ </Text>
177
+ </div>
178
+ ) : null}
179
+ {defaultHarnessError ? (
180
+ <div className="px-20 pb-8">
181
+ <Callout role="alert" type="error">
182
+ <div className="flex flex-col gap-8">
183
+ <Text size="sm" bold>
184
+ Could not save default harness
185
+ </Text>
186
+ <Text size="sm">{defaultHarnessError}</Text>
187
+ </div>
188
+ </Callout>
189
+ </div>
190
+ ) : null}
191
+ {selectedEntry ? (
192
+ <ModelProviderTestAndSaveForm
193
+ workspaceId={workspaceId}
194
+ entry={selectedEntry}
195
+ setAsDefaultOnSave
196
+ onSaved={() => {
197
+ void handleProviderSaved();
198
+ }}
199
+ />
200
+ ) : null}
201
+ </ModalContent>
202
+ </Modal>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ function HarnessPicker({onSelect}: {onSelect: (harness: Harness) => void}) {
208
+ return (
209
+ <ul className={PROVIDER_GRID_CLASS} aria-label="Agent harnesses">
210
+ {listHarnessDescriptors().map((harness) => (
211
+ <HarnessCard key={harness.id} harness={harness} onChoose={() => onSelect(harness.id)} />
212
+ ))}
213
+ </ul>
214
+ );
215
+ }
216
+
217
+ function HarnessCard({harness, onChoose}: {harness: HarnessDescriptor; onChoose: () => void}) {
218
+ return (
219
+ <li>
220
+ <button
221
+ type="button"
222
+ className={cn(
223
+ 'group block w-full cursor-pointer p-16 text-left outline-none transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus',
224
+ SURFACE_CLASS,
225
+ )}
226
+ aria-label={`Choose ${harness.label}`}
227
+ onClick={onChoose}
228
+ >
229
+ <div className="flex min-w-0 items-center justify-between gap-12">
230
+ <div className="flex min-w-0 flex-col gap-4">
231
+ <Text size="md" bold className="min-w-0 truncate">
232
+ {harness.label}
233
+ </Text>
234
+ <Text size="sm" className="text-foreground-neutral-muted">
235
+ {harness.description}
236
+ </Text>
237
+ </div>
238
+ <div className="flex shrink-0 items-center gap-4 text-foreground-neutral-muted transition-colors group-hover:text-foreground-highlight-interactive">
239
+ <Text size="sm">Choose</Text>
240
+ <Icon name="chevronRight" className="size-16" />
241
+ </div>
242
+ </div>
243
+ </button>
244
+ </li>
245
+ );
246
+ }
247
+
248
+ function ModelProviderPicker({
249
+ catalogQuery,
250
+ supportedProviders,
251
+ onSelect,
252
+ }: {
253
+ catalogQuery: ReturnType<typeof useModelProviderCatalogQuery>;
254
+ supportedProviders: ModelProviderCatalogEntryDto[];
255
+ onSelect: (entry: ModelProviderCatalogEntryDto) => void;
256
+ }) {
257
+ if (catalogQuery.isPending) {
258
+ return (
259
+ <div role="status" aria-busy="true" aria-label="Loading model providers">
260
+ <ModelProviderGridSkeleton />
261
+ </div>
262
+ );
263
+ }
264
+
265
+ if (catalogQuery.isError && catalogQuery.data === undefined) {
266
+ return <QueryLoadError query={catalogQuery} subject="model provider catalog" />;
267
+ }
268
+
269
+ if (catalogQuery.data !== undefined && supportedProviders.length === 0) {
270
+ return (
271
+ <EmptyState
272
+ icon="componentLine"
273
+ title="No providers available to configure"
274
+ description="Skip for now and add one later from workspace settings."
275
+ />
276
+ );
277
+ }
278
+
279
+ return <AvailableProvidersGrid entries={supportedProviders} onSelect={onSelect} />;
280
+ }
281
+
282
+ function ModelProviderGridSkeleton() {
283
+ return (
284
+ <div className={PROVIDER_GRID_CLASS}>
285
+ {[0, 1, 2, 3].map((card) => (
286
+ <Skeleton key={card} className="h-136 w-full" />
287
+ ))}
288
+ </div>
289
+ );
290
+ }
@@ -0,0 +1,20 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {useNavigate, useParams} from '@tanstack/react-router';
3
+ import {ModelProviderOnboardingPage} from '#pages/model-provider-onboarding-page.js';
4
+
5
+ export default defineRoute({
6
+ component: () => {
7
+ const {wid} = useParams({strict: false}) as {wid: string};
8
+ const navigate = useNavigate();
9
+ const goToProjectCreation = () => {
10
+ void navigate({to: '/workspaces/$wid/projects/new', params: {wid}, replace: true});
11
+ };
12
+ return (
13
+ <ModelProviderOnboardingPage
14
+ workspaceId={wid}
15
+ onSkip={goToProjectCreation}
16
+ onConfigured={goToProjectCreation}
17
+ />
18
+ );
19
+ },
20
+ });
@@ -0,0 +1,55 @@
1
+ import {
2
+ dismissModelProviderOnboarding,
3
+ isModelProviderOnboardingDismissed,
4
+ } from './model-provider-onboarding.js';
5
+
6
+ function createStorage(initial: Record<string, string> = {}) {
7
+ const store = new Map(Object.entries(initial));
8
+ return {
9
+ getItem: vi.fn((key: string) => store.get(key) ?? null),
10
+ setItem: vi.fn((key: string, value: string) => {
11
+ store.set(key, value);
12
+ }),
13
+ };
14
+ }
15
+
16
+ describe('model provider onboarding dismissed state', () => {
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals();
19
+ });
20
+
21
+ test('round-trips dismissed workspaces through localStorage', () => {
22
+ const localStorage = createStorage();
23
+ vi.stubGlobal('window', {localStorage});
24
+
25
+ dismissModelProviderOnboarding('workspace-1');
26
+
27
+ expect(isModelProviderOnboardingDismissed('workspace-1')).toBe(true);
28
+ expect(isModelProviderOnboardingDismissed('workspace-2')).toBe(false);
29
+ });
30
+
31
+ test('keeps dismissed state isolated per workspace', () => {
32
+ const localStorage = createStorage({
33
+ 'shipfox.modelProviderOnboardingDismissed': JSON.stringify({'workspace-1': true}),
34
+ });
35
+ vi.stubGlobal('window', {localStorage});
36
+
37
+ expect(isModelProviderOnboardingDismissed('workspace-1')).toBe(true);
38
+ expect(isModelProviderOnboardingDismissed('workspace-2')).toBe(false);
39
+ });
40
+
41
+ test('is safe without window', () => {
42
+ expect(isModelProviderOnboardingDismissed('workspace-1')).toBe(false);
43
+
44
+ expect(() => dismissModelProviderOnboarding('workspace-1')).not.toThrow();
45
+ });
46
+
47
+ test('treats corrupt JSON as empty state', () => {
48
+ const localStorage = createStorage({
49
+ 'shipfox.modelProviderOnboardingDismissed': '{',
50
+ });
51
+ vi.stubGlobal('window', {localStorage});
52
+
53
+ expect(isModelProviderOnboardingDismissed('workspace-1')).toBe(false);
54
+ });
55
+ });
@@ -0,0 +1,34 @@
1
+ const MODEL_PROVIDER_ONBOARDING_STORAGE_KEY = 'shipfox.modelProviderOnboardingDismissed';
2
+
3
+ type DismissedMap = Record<string, true>;
4
+
5
+ export function isModelProviderOnboardingDismissed(workspaceId: string): boolean {
6
+ return Boolean(readDismissedMap()[workspaceId]);
7
+ }
8
+
9
+ export function dismissModelProviderOnboarding(workspaceId: string): void {
10
+ if (typeof window === 'undefined') return;
11
+
12
+ const dismissed = readDismissedMap();
13
+ dismissed[workspaceId] = true;
14
+ window.localStorage.setItem(MODEL_PROVIDER_ONBOARDING_STORAGE_KEY, JSON.stringify(dismissed));
15
+ }
16
+
17
+ function readDismissedMap(): DismissedMap {
18
+ if (typeof window === 'undefined') return {};
19
+
20
+ try {
21
+ const raw = window.localStorage.getItem(MODEL_PROVIDER_ONBOARDING_STORAGE_KEY);
22
+ if (!raw) return {};
23
+ const parsed: unknown = JSON.parse(raw);
24
+ if (!isDismissedMap(parsed)) return {};
25
+ return parsed;
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function isDismissedMap(value: unknown): value is DismissedMap {
32
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
33
+ return Object.values(value).every((entry) => entry === true);
34
+ }
@@ -0,0 +1,111 @@
1
+ import type {
2
+ CustomModelProviderConfigDto,
3
+ ListModelProviderConfigsResponseDto,
4
+ ModelProviderCatalogEntryDto,
5
+ ModelProviderCatalogResponseDto,
6
+ ModelProviderConfigDto,
7
+ } from '@shipfox/api-agent-dto';
8
+
9
+ export const AGENT_TEST_WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
10
+
11
+ const TEST_PROVIDER_IDS = [
12
+ 'anthropic',
13
+ 'openai',
14
+ 'deepseek',
15
+ 'nvidia',
16
+ 'google',
17
+ 'mistral',
18
+ 'groq',
19
+ 'cerebras',
20
+ 'xai',
21
+ ] as const;
22
+
23
+ export function modelProviderEntry(
24
+ overrides: Partial<ModelProviderCatalogEntryDto> = {},
25
+ ): ModelProviderCatalogEntryDto {
26
+ return {
27
+ id: 'anthropic',
28
+ label: 'Anthropic',
29
+ support_status: 'supported',
30
+ default_model: null,
31
+ credential_fields: [{key: 'api_key', label: 'API key', secret: true}],
32
+ unsupported_reason: null,
33
+ models: [{id: 'claude-opus-4-8', label: 'Claude Opus 4.8'}],
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ export function unsupportedModelProviderEntry(
39
+ overrides: Partial<ModelProviderCatalogEntryDto> = {},
40
+ ): ModelProviderCatalogEntryDto {
41
+ return {
42
+ id: 'amazon-bedrock',
43
+ label: 'Amazon Bedrock',
44
+ support_status: 'unsupported',
45
+ default_model: null,
46
+ credential_fields: [],
47
+ unsupported_reason: 'AWS cloud credentials are not supported yet.',
48
+ models: [],
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ export function testModelProviderEntries(count: number): ModelProviderCatalogEntryDto[] {
54
+ return TEST_PROVIDER_IDS.slice(0, count).map((id, index) =>
55
+ modelProviderEntry({
56
+ id,
57
+ label: `Provider ${index}`,
58
+ default_model: `model-${index}`,
59
+ models: [{id: `model-${index}`, label: `Model ${index}`}],
60
+ }),
61
+ );
62
+ }
63
+
64
+ export function modelProviderConfig(
65
+ overrides: Partial<Omit<ModelProviderConfigDto, 'kind'>> = {},
66
+ ): ModelProviderConfigDto {
67
+ return {
68
+ kind: 'builtin',
69
+ provider_id: 'anthropic',
70
+ default_model: null,
71
+ created_at: '2026-05-08T00:00:00.000Z',
72
+ updated_at: '2026-05-08T00:00:00.000Z',
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ export function customModelProviderConfig(
78
+ overrides: Partial<Omit<CustomModelProviderConfigDto, 'kind'>> = {},
79
+ ): CustomModelProviderConfigDto {
80
+ return {
81
+ kind: 'custom',
82
+ provider_id: 'openai-compatible',
83
+ display_name: 'OpenAI Compatible',
84
+ base_url: 'https://models.example.test/v1',
85
+ api: 'openai-responses',
86
+ headers: [],
87
+ secret_header_names: [],
88
+ default_model: 'custom-model',
89
+ models: [{id: 'custom-model', label: 'Custom Model'}],
90
+ created_at: '2026-05-08T00:00:00.000Z',
91
+ updated_at: '2026-05-08T00:00:00.000Z',
92
+ ...overrides,
93
+ };
94
+ }
95
+
96
+ export function modelProviderCatalogResponse(
97
+ modelProviders: ModelProviderCatalogEntryDto[] = [modelProviderEntry()],
98
+ ): ModelProviderCatalogResponseDto {
99
+ return {providers: modelProviders};
100
+ }
101
+
102
+ export function modelProviderConfigsResponse(
103
+ overrides: Partial<ListModelProviderConfigsResponseDto> = {},
104
+ ): ListModelProviderConfigsResponseDto {
105
+ return {
106
+ configs: [modelProviderConfig()],
107
+ default_harness_id: null,
108
+ default_provider_id: 'anthropic',
109
+ ...overrides,
110
+ };
111
+ }
package/test/setup.ts ADDED
@@ -0,0 +1,3 @@
1
+ import {installClientDomTestEnv} from '@shipfox/client-test-setup';
2
+
3
+ installClientDomTestEnv();
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@shipfox/ts-config/react",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["**/*.test.tsx", "**/*.test.ts"]
9
+ }