@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,645 @@
1
+ import type {
2
+ CustomModelProviderConfigDto,
3
+ ModelProviderCatalogEntryDto,
4
+ ModelProviderConfigDto,
5
+ ModelProviderConfigResponseDto,
6
+ } from '@shipfox/api-agent-dto';
7
+ import {QueryLoadError} from '@shipfox/client-ui';
8
+ import {Button, IconButton} from '@shipfox/react-ui/button';
9
+ import {Callout} from '@shipfox/react-ui/callout';
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ } from '@shipfox/react-ui/dropdown-menu';
16
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
17
+ import {Icon} from '@shipfox/react-ui/icon';
18
+ import {
19
+ Modal,
20
+ ModalBody,
21
+ ModalContent,
22
+ ModalFooter,
23
+ ModalHeader,
24
+ ModalTitle,
25
+ } from '@shipfox/react-ui/modal';
26
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
27
+ import {toast} from '@shipfox/react-ui/toast';
28
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
29
+ import {Header, Text} from '@shipfox/react-ui/typography';
30
+ import {cn} from '@shipfox/react-ui/utils';
31
+ import {useEffect, useMemo, useRef, useState} from 'react';
32
+ import {
33
+ useDeleteModelProviderConfigMutation,
34
+ useModelProviderCatalogQuery,
35
+ useModelProviderConfigsQuery,
36
+ useSetDefaultModelProviderMutation,
37
+ } from '#hooks/api/model-providers.js';
38
+ import {AddCustomProviderCard} from './add-custom-provider-card.js';
39
+ import {AvailableProvidersGrid, PROVIDER_GRID_CLASS} from './available-providers-grid.js';
40
+ import {ChangeDefaultModelForm} from './change-default-model-form.js';
41
+ import {CustomModelProviderForm} from './custom-model-provider-form.js';
42
+ import {modelProviderConfigErrorToFormError} from './form-errors.js';
43
+ import {ModelProviderUsageModal} from './model-provider-usage-modal.js';
44
+ import {
45
+ type ModelProviderUsageTarget,
46
+ usageTargetFromCatalogEntry,
47
+ usageTargetFromCustomConfig,
48
+ } from './model-provider-usage-target.js';
49
+ import {customProviderCardMatchesSearch} from './provider-search.js';
50
+ import {
51
+ isSupportedCatalogEntry,
52
+ type SupportedModelProviderCatalogEntry,
53
+ toSupportedCatalogEntry,
54
+ } from './supported-model-provider-catalog-entry.js';
55
+ import {ModelProviderTestAndSaveForm} from './test-and-save-form.js';
56
+
57
+ const SURFACE_CLASS =
58
+ 'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
59
+
60
+ type ModelProviderFormState =
61
+ | {mode: 'configure'; entry: SupportedModelProviderCatalogEntry; config?: undefined}
62
+ | {mode: 'edit'; entry: SupportedModelProviderCatalogEntry; config: ModelProviderConfigDto};
63
+ type CustomModelProviderFormState =
64
+ | {mode: 'create'}
65
+ | {mode: 'edit'; config: CustomModelProviderConfigDto};
66
+ type ModelFormState = {entry: SupportedModelProviderCatalogEntry; config: ModelProviderConfigDto};
67
+ type UsageTarget = {
68
+ target: ModelProviderUsageTarget;
69
+ initialModel: string | null;
70
+ restoreFocusToConfiguredProviders: boolean;
71
+ };
72
+
73
+ const USAGE_MODAL_OPEN_DELAY_MS = 250;
74
+
75
+ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: string}) {
76
+ const catalogQuery = useModelProviderCatalogQuery();
77
+ const configsQuery = useModelProviderConfigsQuery(workspaceId);
78
+ const [formState, setFormState] = useState<ModelProviderFormState | null>(null);
79
+ const [customFormState, setCustomFormState] = useState<CustomModelProviderFormState | null>(null);
80
+ const [modelFormState, setModelFormState] = useState<ModelFormState | null>(null);
81
+ const [usageTarget, setUsageTarget] = useState<UsageTarget | null>(null);
82
+ const [pendingUsageTarget, setPendingUsageTarget] = useState<UsageTarget | null>(null);
83
+ const configuredProvidersRegionRef = useRef<HTMLElement | null>(null);
84
+
85
+ const providers = catalogQuery.data?.providers ?? [];
86
+ const configs = configsQuery.data?.configs ?? [];
87
+ const configsLoaded = configsQuery.data !== undefined;
88
+ const defaultProviderId = configsQuery.data?.default_provider_id ?? null;
89
+ const providerById = useMemo(
90
+ () =>
91
+ new Map<string, ModelProviderCatalogEntryDto>(
92
+ providers.map((provider) => [provider.id, provider]),
93
+ ),
94
+ [providers],
95
+ );
96
+ const configuredIds = useMemo(
97
+ () => new Set<string>(configs.map((config) => config.provider_id)),
98
+ [configs],
99
+ );
100
+ const availableProviders = configsLoaded
101
+ ? providers.filter(
102
+ (provider): provider is SupportedModelProviderCatalogEntry =>
103
+ isSupportedCatalogEntry(provider) && !configuredIds.has(provider.id),
104
+ )
105
+ : [];
106
+ const unsupportedProviders = providers.filter(
107
+ (provider) => provider.support_status === 'unsupported',
108
+ );
109
+
110
+ function closeForm() {
111
+ setFormState(null);
112
+ }
113
+
114
+ function closeModelForm() {
115
+ setModelFormState(null);
116
+ }
117
+
118
+ function closeCustomForm() {
119
+ setCustomFormState(null);
120
+ }
121
+
122
+ useEffect(() => {
123
+ if (pendingUsageTarget === null || formState !== null || customFormState !== null)
124
+ return undefined;
125
+
126
+ const timer = window.setTimeout(() => {
127
+ setUsageTarget(pendingUsageTarget);
128
+ setPendingUsageTarget(null);
129
+ }, USAGE_MODAL_OPEN_DELAY_MS);
130
+
131
+ return () => window.clearTimeout(timer);
132
+ }, [customFormState, formState, pendingUsageTarget]);
133
+
134
+ return (
135
+ <div className="flex min-w-0 flex-col gap-32">
136
+ <section
137
+ ref={configuredProvidersRegionRef}
138
+ className="flex flex-col gap-16 outline-none"
139
+ aria-label="Configured providers"
140
+ tabIndex={-1}
141
+ >
142
+ <div className="flex flex-col gap-4">
143
+ <Header variant="h3">Configured providers</Header>
144
+ <Text size="sm" className="text-foreground-neutral-muted">
145
+ Workspace credentials available to agent steps.
146
+ </Text>
147
+ </div>
148
+
149
+ {configsQuery.isPending ? (
150
+ <ModelProviderRowsSkeleton label="Loading configured providers" />
151
+ ) : null}
152
+
153
+ {configsQuery.isError && configsQuery.data === undefined ? (
154
+ <div className={cn(SURFACE_CLASS, 'px-16')}>
155
+ <QueryLoadError query={configsQuery} subject="model provider configs" />
156
+ </div>
157
+ ) : null}
158
+
159
+ {configsQuery.data !== undefined && configs.length === 0 ? (
160
+ <div className={cn(SURFACE_CLASS, 'px-16')}>
161
+ <EmptyState
162
+ icon="key2Line"
163
+ title="No providers configured"
164
+ description="Configure a provider below to run agent steps with workspace-managed credentials."
165
+ />
166
+ </div>
167
+ ) : null}
168
+
169
+ {configs.length > 0 ? (
170
+ <ul className={cn('divide-y divide-border-neutral-base', SURFACE_CLASS)}>
171
+ {configs.map((config) => {
172
+ const entry = toSupportedCatalogEntry(providerById.get(config.provider_id));
173
+ const builtinConfig = isBuiltinModelProviderConfig(config) ? config : undefined;
174
+ const customConfig = isCustomModelProviderConfig(config) ? config : undefined;
175
+ return (
176
+ <ConfiguredProviderRow
177
+ key={config.provider_id}
178
+ workspaceId={workspaceId}
179
+ config={config}
180
+ entry={entry}
181
+ isDefault={config.provider_id === defaultProviderId}
182
+ onEdit={() => {
183
+ if (entry && builtinConfig) {
184
+ setFormState({mode: 'edit', entry, config: builtinConfig});
185
+ } else if (customConfig) {
186
+ setCustomFormState({mode: 'edit', config: customConfig});
187
+ }
188
+ }}
189
+ onChangeDefaultModel={() => {
190
+ if (entry && builtinConfig) setModelFormState({entry, config: builtinConfig});
191
+ }}
192
+ onShowUsage={() => {
193
+ if (entry && builtinConfig) {
194
+ setPendingUsageTarget(null);
195
+ setUsageTarget({
196
+ target: usageTargetFromCatalogEntry(entry),
197
+ initialModel: builtinConfig.default_model,
198
+ restoreFocusToConfiguredProviders: false,
199
+ });
200
+ } else if (customConfig) {
201
+ setPendingUsageTarget(null);
202
+ setUsageTarget({
203
+ target: usageTargetFromCustomConfig(customConfig),
204
+ initialModel: customConfig.default_model,
205
+ restoreFocusToConfiguredProviders: false,
206
+ });
207
+ }
208
+ }}
209
+ />
210
+ );
211
+ })}
212
+ </ul>
213
+ ) : null}
214
+ </section>
215
+
216
+ <section className="flex flex-col gap-16" aria-label="Available providers">
217
+ <div className="flex flex-col gap-4">
218
+ <Header variant="h3">Available providers</Header>
219
+ <Text size="sm" className="text-foreground-neutral-muted">
220
+ Providers that can be configured for agent steps in this workspace.
221
+ </Text>
222
+ </div>
223
+
224
+ {catalogQuery.isPending || configsQuery.isPending ? <ModelProviderGridSkeleton /> : null}
225
+
226
+ {catalogQuery.isError && catalogQuery.data === undefined ? (
227
+ <div className={cn(SURFACE_CLASS, 'px-16')}>
228
+ <QueryLoadError query={catalogQuery} subject="model provider catalog" />
229
+ </div>
230
+ ) : null}
231
+
232
+ {configsLoaded ? (
233
+ <AvailableProvidersGrid
234
+ entries={availableProviders}
235
+ onSelect={(entry) => setFormState({mode: 'configure', entry})}
236
+ trailingCard={
237
+ <AddCustomProviderCard onConfigure={() => setCustomFormState({mode: 'create'})} />
238
+ }
239
+ trailingCardMatchesSearch={customProviderCardMatchesSearch}
240
+ />
241
+ ) : null}
242
+ </section>
243
+
244
+ <section className="flex flex-col gap-16" aria-label="Unsupported providers">
245
+ <div className="flex flex-col gap-4">
246
+ <Header variant="h3">Unsupported providers</Header>
247
+ <Text size="sm" className="text-foreground-neutral-muted">
248
+ Providers that cannot be configured in this workspace yet.
249
+ </Text>
250
+ </div>
251
+
252
+ {catalogQuery.isPending ? (
253
+ <ModelProviderRowsSkeleton label="Loading unsupported providers" />
254
+ ) : null}
255
+
256
+ {unsupportedProviders.length > 0 ? (
257
+ <ul className={cn('divide-y divide-border-neutral-base', SURFACE_CLASS)}>
258
+ {unsupportedProviders.map((entry) => (
259
+ <li key={entry.id} className="flex items-start gap-12 px-16 py-12 opacity-70">
260
+ <Icon
261
+ name="forbid2Line"
262
+ className="mt-2 size-18 shrink-0 text-foreground-neutral-muted"
263
+ aria-hidden
264
+ />
265
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
266
+ <Text size="md" bold className="truncate">
267
+ {entry.label}
268
+ </Text>
269
+ <Text size="sm" className="text-foreground-neutral-muted">
270
+ {entry.unsupported_reason}
271
+ </Text>
272
+ </div>
273
+ </li>
274
+ ))}
275
+ </ul>
276
+ ) : null}
277
+ </section>
278
+
279
+ <Modal open={formState !== null} onOpenChange={(open) => (open ? undefined : closeForm())}>
280
+ <ModalContent aria-describedby={undefined}>
281
+ <ModalTitle className="sr-only">{modelProviderFormTitle(formState)}</ModalTitle>
282
+ <ModalHeader>
283
+ <Text
284
+ size="lg"
285
+ aria-hidden="true"
286
+ className="overflow-ellipsis overflow-hidden whitespace-nowrap"
287
+ >
288
+ {modelProviderFormTitle(formState)}
289
+ </Text>
290
+ </ModalHeader>
291
+ {formState ? (
292
+ <ModelProviderTestAndSaveForm
293
+ workspaceId={workspaceId}
294
+ entry={formState.entry}
295
+ existingConfig={formState.config}
296
+ onSaved={(savedDefaultModel) => {
297
+ toast.success(`${formState.entry.label} saved`);
298
+ if (formState.mode === 'configure' && configs.length === 0) {
299
+ setPendingUsageTarget({
300
+ target: usageTargetFromCatalogEntry(formState.entry),
301
+ initialModel: savedDefaultModel,
302
+ restoreFocusToConfiguredProviders: true,
303
+ });
304
+ }
305
+ closeForm();
306
+ }}
307
+ />
308
+ ) : null}
309
+ </ModalContent>
310
+ </Modal>
311
+
312
+ <Modal
313
+ open={modelFormState !== null}
314
+ onOpenChange={(open) => (open ? undefined : closeModelForm())}
315
+ >
316
+ <ModalContent aria-describedby={undefined}>
317
+ <ModalTitle className="sr-only">Change default model</ModalTitle>
318
+ <ModalHeader>
319
+ <Text
320
+ size="lg"
321
+ aria-hidden="true"
322
+ className="overflow-ellipsis overflow-hidden whitespace-nowrap"
323
+ >
324
+ Change default model for {modelFormState?.entry.label}
325
+ </Text>
326
+ </ModalHeader>
327
+ {modelFormState ? (
328
+ <ChangeDefaultModelForm
329
+ workspaceId={workspaceId}
330
+ entry={modelFormState.entry}
331
+ config={modelFormState.config}
332
+ onSaved={() => {
333
+ toast.success(`${modelFormState.entry.label} default model saved`);
334
+ closeModelForm();
335
+ }}
336
+ />
337
+ ) : null}
338
+ </ModalContent>
339
+ </Modal>
340
+
341
+ <ModelProviderUsageModal
342
+ target={usageTarget?.target ?? null}
343
+ initialModel={usageTarget?.initialModel ?? null}
344
+ workspaceDefaultHarnessId={configsQuery.data?.default_harness_id ?? null}
345
+ open={usageTarget !== null}
346
+ closeFocusTarget={
347
+ usageTarget?.restoreFocusToConfiguredProviders
348
+ ? configuredProvidersRegionRef.current
349
+ : null
350
+ }
351
+ onOpenChange={(open) => {
352
+ if (!open) setUsageTarget(null);
353
+ }}
354
+ />
355
+
356
+ <Modal
357
+ open={customFormState !== null}
358
+ onOpenChange={(open) => (open ? undefined : closeCustomForm())}
359
+ >
360
+ <ModalContent
361
+ aria-describedby={undefined}
362
+ className="max-h-[calc(100vh-32px)] max-w-[760px]"
363
+ >
364
+ <ModalTitle className="sr-only">
365
+ {customModelProviderFormTitle(customFormState)}
366
+ </ModalTitle>
367
+ <ModalHeader>
368
+ <div className="flex min-w-0 flex-col gap-2">
369
+ <Text size="lg" aria-hidden="true" className="truncate">
370
+ {customModelProviderFormTitle(customFormState)}
371
+ </Text>
372
+ <Text size="sm" className="text-foreground-neutral-muted">
373
+ Connect an OpenAI-, Anthropic-, or Gemini-compatible endpoint.
374
+ </Text>
375
+ </div>
376
+ </ModalHeader>
377
+ {customFormState ? (
378
+ <CustomModelProviderForm
379
+ workspaceId={workspaceId}
380
+ existingConfig={customFormState.mode === 'edit' ? customFormState.config : undefined}
381
+ onSaved={() => {
382
+ toast.success(
383
+ customFormState.mode === 'edit'
384
+ ? `${customFormState.config.display_name} saved`
385
+ : 'Custom provider saved',
386
+ );
387
+ closeCustomForm();
388
+ }}
389
+ />
390
+ ) : null}
391
+ </ModalContent>
392
+ </Modal>
393
+ </div>
394
+ );
395
+ }
396
+
397
+ function modelProviderFormTitle(formState: ModelProviderFormState | null): string {
398
+ if (formState === null) return '';
399
+ if (formState.mode === 'edit') return `Edit credentials for ${formState.entry.label}`;
400
+ return `Configure ${formState.entry.label}`;
401
+ }
402
+
403
+ function customModelProviderFormTitle(formState: CustomModelProviderFormState | null): string {
404
+ if (formState === null) return '';
405
+ if (formState.mode === 'edit') return `Edit ${formState.config.display_name}`;
406
+ return 'Add custom provider';
407
+ }
408
+
409
+ function isBuiltinModelProviderConfig(
410
+ config: ModelProviderConfigResponseDto,
411
+ ): config is ModelProviderConfigDto {
412
+ return config.kind === 'builtin';
413
+ }
414
+
415
+ function isCustomModelProviderConfig(
416
+ config: ModelProviderConfigResponseDto,
417
+ ): config is CustomModelProviderConfigDto {
418
+ return config.kind === 'custom';
419
+ }
420
+
421
+ function ConfiguredProviderRow({
422
+ workspaceId,
423
+ config,
424
+ entry,
425
+ isDefault,
426
+ onEdit,
427
+ onChangeDefaultModel,
428
+ onShowUsage,
429
+ }: {
430
+ workspaceId: string;
431
+ config: ModelProviderConfigResponseDto;
432
+ entry: SupportedModelProviderCatalogEntry | undefined;
433
+ isDefault: boolean;
434
+ onEdit: () => void;
435
+ onChangeDefaultModel: () => void;
436
+ onShowUsage: () => void;
437
+ }) {
438
+ const setDefault = useSetDefaultModelProviderMutation();
439
+ const deleteConfig = useDeleteModelProviderConfigMutation();
440
+ const [defaultError, setDefaultError] = useState<string | undefined>();
441
+ const [deleteOpen, setDeleteOpen] = useState(false);
442
+ const [deleteError, setDeleteError] = useState<string | undefined>();
443
+ const customConfig = isCustomModelProviderConfig(config) ? config : undefined;
444
+ const label = customConfig?.display_name ?? entry?.label ?? config.provider_id;
445
+ const canUse = entry !== undefined || customConfig !== undefined;
446
+ const canEdit = entry !== undefined || customConfig !== undefined;
447
+ const isBuiltinConfig = isBuiltinModelProviderConfig(config);
448
+
449
+ async function handleSetDefault() {
450
+ setDefaultError(undefined);
451
+ try {
452
+ await setDefault.mutateAsync({
453
+ workspaceId,
454
+ body: {provider_id: config.provider_id},
455
+ });
456
+ toast.success(`${label} is now the default provider`);
457
+ } catch (error) {
458
+ const mapped = modelProviderConfigErrorToFormError(error);
459
+ setDefaultError(mapped.message);
460
+ }
461
+ }
462
+
463
+ async function handleDelete() {
464
+ setDeleteError(undefined);
465
+ try {
466
+ await deleteConfig.mutateAsync({workspaceId, providerId: config.provider_id});
467
+ toast.success(`${label} deleted`);
468
+ setDeleteOpen(false);
469
+ } catch (error) {
470
+ const mapped = modelProviderConfigErrorToFormError(error);
471
+ setDeleteError(mapped.message);
472
+ }
473
+ }
474
+
475
+ function handleDeleteOpenChange(nextOpen: boolean) {
476
+ setDeleteOpen(nextOpen);
477
+ if (nextOpen) {
478
+ setDeleteError(undefined);
479
+ deleteConfig.reset();
480
+ }
481
+ }
482
+
483
+ return (
484
+ <li className="flex flex-col gap-10 px-16 py-12 transition-colors hover:bg-background-components-hover">
485
+ <div className="flex items-center justify-between gap-12">
486
+ <div className="flex min-w-0 items-center gap-8">
487
+ {isDefault ? (
488
+ <>
489
+ <Tooltip>
490
+ <TooltipTrigger asChild>
491
+ <span className="inline-flex size-16 shrink-0 items-center justify-center">
492
+ <Icon
493
+ name="starLine"
494
+ className="size-16 text-foreground-neutral-muted"
495
+ aria-hidden
496
+ />
497
+ </span>
498
+ </TooltipTrigger>
499
+ <TooltipContent>Default provider</TooltipContent>
500
+ </Tooltip>
501
+ <span className="sr-only">Default provider</span>
502
+ </>
503
+ ) : null}
504
+ <Text size="md" bold className="truncate">
505
+ {label}
506
+ </Text>
507
+ </div>
508
+ <DropdownMenu>
509
+ <DropdownMenuTrigger asChild>
510
+ <IconButton
511
+ size="sm"
512
+ variant="transparent"
513
+ icon="more2Line"
514
+ aria-label={`Open ${label} provider actions`}
515
+ />
516
+ </DropdownMenuTrigger>
517
+ <DropdownMenuContent align="end">
518
+ {!isDefault ? (
519
+ <DropdownMenuItem
520
+ icon="starLine"
521
+ disabled={setDefault.isPending || (!entry && !customConfig)}
522
+ onSelect={() => {
523
+ void handleSetDefault();
524
+ }}
525
+ >
526
+ Set as default
527
+ </DropdownMenuItem>
528
+ ) : null}
529
+ {isBuiltinConfig ? (
530
+ <DropdownMenuItem
531
+ icon="settings3Line"
532
+ disabled={!entry}
533
+ onSelect={onChangeDefaultModel}
534
+ >
535
+ Change default model
536
+ </DropdownMenuItem>
537
+ ) : null}
538
+ <DropdownMenuItem icon="bookOpenLine" disabled={!canUse} onSelect={onShowUsage}>
539
+ View workflow example
540
+ </DropdownMenuItem>
541
+ <DropdownMenuItem icon="editLine" disabled={!canEdit} onSelect={onEdit}>
542
+ {customConfig ? 'Edit' : 'Edit credentials'}
543
+ </DropdownMenuItem>
544
+ <DropdownMenuItem
545
+ icon="deleteBinLine"
546
+ onSelect={() => {
547
+ setDeleteOpen(true);
548
+ }}
549
+ >
550
+ Delete
551
+ </DropdownMenuItem>
552
+ </DropdownMenuContent>
553
+ </DropdownMenu>
554
+ </div>
555
+ {defaultError ? (
556
+ <Callout role="alert" type="error">
557
+ <Text size="sm">{defaultError}</Text>
558
+ </Callout>
559
+ ) : null}
560
+ <DeleteModelProviderDialog
561
+ open={deleteOpen}
562
+ onOpenChange={handleDeleteOpenChange}
563
+ label={label}
564
+ errorMessage={deleteError}
565
+ isLoading={deleteConfig.isPending}
566
+ onDelete={handleDelete}
567
+ />
568
+ </li>
569
+ );
570
+ }
571
+
572
+ function DeleteModelProviderDialog({
573
+ open,
574
+ onOpenChange,
575
+ label,
576
+ errorMessage,
577
+ isLoading,
578
+ onDelete,
579
+ }: {
580
+ open: boolean;
581
+ onOpenChange: (open: boolean) => void;
582
+ label: string;
583
+ errorMessage: string | undefined;
584
+ isLoading: boolean;
585
+ onDelete: () => void;
586
+ }) {
587
+ return (
588
+ <Modal open={open} onOpenChange={onOpenChange}>
589
+ <ModalContent aria-describedby={undefined} className="max-w-[420px]">
590
+ <ModalTitle className="sr-only">Delete model provider</ModalTitle>
591
+ <ModalHeader title="Delete model provider" />
592
+ <ModalBody className="gap-16">
593
+ <Text size="sm" className="text-foreground-neutral-muted">
594
+ Delete {label} credentials from this workspace? Agent jobs cannot use this provider
595
+ until it is configured again.
596
+ </Text>
597
+ {errorMessage ? (
598
+ <Callout role="alert" type="error">
599
+ <Text size="sm">{errorMessage}</Text>
600
+ </Callout>
601
+ ) : null}
602
+ </ModalBody>
603
+ <ModalFooter>
604
+ <Button size="sm" variant="secondary" onClick={() => onOpenChange(false)}>
605
+ Cancel
606
+ </Button>
607
+ <Button size="sm" variant="danger" isLoading={isLoading} onClick={onDelete}>
608
+ Delete
609
+ </Button>
610
+ </ModalFooter>
611
+ </ModalContent>
612
+ </Modal>
613
+ );
614
+ }
615
+
616
+ function ModelProviderRowsSkeleton({label}: {label: string}) {
617
+ return (
618
+ <ul
619
+ role="status"
620
+ aria-label={label}
621
+ className={cn('divide-y divide-border-neutral-base', SURFACE_CLASS)}
622
+ >
623
+ {[0, 1, 2].map((row) => (
624
+ <li key={row} className="flex items-center gap-12 px-16 py-12">
625
+ <Skeleton className="size-32 shrink-0" />
626
+ <div className="flex min-w-0 flex-1 flex-col gap-6">
627
+ <Skeleton className="h-16 w-120" />
628
+ <Skeleton className="h-14 w-180" />
629
+ </div>
630
+ <Skeleton className="h-28 w-96 shrink-0" />
631
+ </li>
632
+ ))}
633
+ </ul>
634
+ );
635
+ }
636
+
637
+ function ModelProviderGridSkeleton() {
638
+ return (
639
+ <div role="status" aria-label="Loading available providers" className={PROVIDER_GRID_CLASS}>
640
+ {[0, 1, 2, 3].map((card) => (
641
+ <Skeleton key={card} className="h-136 w-full" />
642
+ ))}
643
+ </div>
644
+ );
645
+ }
@@ -0,0 +1,45 @@
1
+ import {modelProviderEntry} from '#test/fixtures/model-providers.js';
2
+ import {providerMatchesSearch} from './provider-search.js';
3
+
4
+ describe('providerMatchesSearch', () => {
5
+ test('matches empty and whitespace queries', () => {
6
+ const entry = modelProviderEntry();
7
+
8
+ expect(providerMatchesSearch(entry, '')).toBe(true);
9
+ expect(providerMatchesSearch(entry, ' ')).toBe(true);
10
+ });
11
+
12
+ test('matches provider labels case-insensitively', () => {
13
+ const entry = modelProviderEntry({label: 'Anthropic'});
14
+
15
+ const result = providerMatchesSearch(entry, 'anthro');
16
+
17
+ expect(result).toBe(true);
18
+ });
19
+
20
+ test('matches provider id substrings', () => {
21
+ const entry = modelProviderEntry({id: 'azure-openai-responses', label: 'Azure OpenAI'});
22
+
23
+ const result = providerMatchesSearch(entry, 'responses');
24
+
25
+ expect(result).toBe(true);
26
+ });
27
+
28
+ test('matches model ids and labels', () => {
29
+ const entry = modelProviderEntry({
30
+ models: [{id: 'claude-opus-4-8', label: 'Claude Opus 4.8'}],
31
+ });
32
+
33
+ const result = providerMatchesSearch(entry, 'claude');
34
+
35
+ expect(result).toBe(true);
36
+ });
37
+
38
+ test('returns false when no searchable field matches', () => {
39
+ const entry = modelProviderEntry();
40
+
41
+ const result = providerMatchesSearch(entry, 'gemini');
42
+
43
+ expect(result).toBe(false);
44
+ });
45
+ });