@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,326 @@
1
+ import {
2
+ DEFAULT_HARNESS,
3
+ getHarnessDescriptor,
4
+ type Harness,
5
+ listHarnessDescriptors,
6
+ } from '@shipfox/api-agent-dto';
7
+ import {Button} from '@shipfox/react-ui/button';
8
+ import {
9
+ CodeBlock,
10
+ CodeBlockBody,
11
+ CodeBlockContent,
12
+ CodeBlockCopyButton,
13
+ CodeBlockFilename,
14
+ CodeBlockFiles,
15
+ CodeBlockHeader,
16
+ CodeBlockItem,
17
+ } from '@shipfox/react-ui/code-block';
18
+ import {Combobox} from '@shipfox/react-ui/combobox';
19
+ import {useCopyToClipboard} from '@shipfox/react-ui/hooks';
20
+ import {
21
+ Modal,
22
+ ModalBody,
23
+ ModalContent,
24
+ ModalFooter,
25
+ ModalHeader,
26
+ ModalTitle,
27
+ } from '@shipfox/react-ui/modal';
28
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
29
+ import {Code, Text} from '@shipfox/react-ui/typography';
30
+ import {useEffect, useMemo, useRef, useState} from 'react';
31
+ import {buildAgentWorkflowExample} from './agent-workflow-example.js';
32
+ import {compatibleHarnessIds} from './harness-availability.js';
33
+ import type {ModelProviderUsageTarget} from './model-provider-usage-target.js';
34
+
35
+ type CopyState = 'idle' | 'copied' | 'failed';
36
+
37
+ const WORKFLOW_EXAMPLE_FILENAME = '.shipfox/workflows/agent.yml';
38
+
39
+ export function ModelProviderUsageModal({
40
+ target,
41
+ initialModel,
42
+ open,
43
+ onOpenChange,
44
+ closeFocusTarget,
45
+ workspaceDefaultHarnessId,
46
+ }: {
47
+ target: ModelProviderUsageTarget | null;
48
+ initialModel?: string | null | undefined;
49
+ open: boolean;
50
+ onOpenChange: (open: boolean) => void;
51
+ closeFocusTarget?: HTMLElement | null | undefined;
52
+ workspaceDefaultHarnessId?: Harness | null | undefined;
53
+ }) {
54
+ const [selectedModel, setSelectedModel] = useState('');
55
+ const [selectedHarness, setSelectedHarness] = useState<Harness>(DEFAULT_HARNESS);
56
+
57
+ useEffect(() => {
58
+ if (!target) return;
59
+ setSelectedModel(initialModel ?? target.default_model ?? target.models[0]?.id ?? '');
60
+ }, [target, initialModel]);
61
+
62
+ useEffect(() => {
63
+ if (!target) return;
64
+ const nextCompatibleHarnessIds = compatibleHarnessIds({
65
+ isCustom: target.isCustom,
66
+ providerId: target.id,
67
+ });
68
+ setSelectedHarness(selectInitialHarness(nextCompatibleHarnessIds, workspaceDefaultHarnessId));
69
+ }, [target, workspaceDefaultHarnessId]);
70
+
71
+ const compatibleIds = useMemo(
72
+ () =>
73
+ target
74
+ ? compatibleHarnessIds({isCustom: target.isCustom, providerId: target.id})
75
+ : ([] as Harness[]),
76
+ [target],
77
+ );
78
+ const harnessOptions = useMemo(
79
+ () =>
80
+ compatibleIds.map((harnessId) => ({
81
+ value: harnessId,
82
+ label: getHarnessDescriptor(harnessId).label,
83
+ })),
84
+ [compatibleIds],
85
+ );
86
+ const modelOptions = useMemo(
87
+ () => target?.models.map((model) => ({value: model.id, label: model.label})) ?? [],
88
+ [target],
89
+ );
90
+ const example = target
91
+ ? buildAgentWorkflowExample({
92
+ harness: selectedHarness,
93
+ providerId: target.id,
94
+ model: selectedModel,
95
+ })
96
+ : null;
97
+ const data =
98
+ example === null
99
+ ? []
100
+ : [{language: 'yaml', filename: WORKFLOW_EXAMPLE_FILENAME, code: example.code}];
101
+
102
+ function handleOpenChange(nextOpen: boolean) {
103
+ onOpenChange(nextOpen);
104
+ if (!nextOpen && closeFocusTarget) {
105
+ window.setTimeout(() => closeFocusTarget.focus(), 0);
106
+ }
107
+ }
108
+
109
+ return (
110
+ <Modal open={open && target !== null} onOpenChange={handleOpenChange}>
111
+ <ModalContent aria-describedby={undefined} className="max-h-[calc(100vh-32px)] max-w-[640px]">
112
+ <ModalTitle className="sr-only">
113
+ {target ? `Use ${target.label} in a workflow` : 'Use provider in a workflow'}
114
+ </ModalTitle>
115
+ <ModalHeader>
116
+ <div className="flex min-w-0 flex-col gap-2">
117
+ <Text size="lg" aria-hidden="true" className="truncate">
118
+ {target ? `Use ${target.label} in a workflow` : 'Use provider in a workflow'}
119
+ </Text>
120
+ <Text size="sm" className="text-foreground-neutral-muted">
121
+ Reference this provider from a workflow agent step.
122
+ </Text>
123
+ </div>
124
+ </ModalHeader>
125
+ {target && example ? (
126
+ <>
127
+ <ModalBody className="min-h-0 flex-1 gap-0 overflow-y-auto overflow-x-clip scrollbar">
128
+ <div className="flex w-full flex-col gap-20">
129
+ {compatibleIds.length === 1 ? (
130
+ <div className="flex flex-col gap-12">
131
+ <div className="flex min-h-40 items-center justify-between gap-12 rounded-8 border border-border-neutral-base px-12 py-8">
132
+ <Text size="sm" className="text-foreground-neutral-muted">
133
+ Harness
134
+ </Text>
135
+ <Code as="span" variant="label">
136
+ {compatibleIds[0]}
137
+ </Code>
138
+ </div>
139
+ <Combobox
140
+ aria-label="Model"
141
+ options={modelOptions}
142
+ value={selectedModel}
143
+ onValueChange={(value) => {
144
+ if (value) setSelectedModel(value);
145
+ }}
146
+ placeholder="Select model..."
147
+ searchPlaceholder="Search models..."
148
+ emptyState="No model found."
149
+ className="w-full"
150
+ />
151
+ </div>
152
+ ) : (
153
+ <div className="flex flex-col gap-12 sm:flex-row sm:gap-12">
154
+ <Combobox
155
+ aria-label="Harness"
156
+ options={harnessOptions}
157
+ value={selectedHarness}
158
+ onValueChange={(value) => {
159
+ if (isHarness(value)) setSelectedHarness(value);
160
+ }}
161
+ placeholder="Select harness..."
162
+ searchPlaceholder="Search harnesses..."
163
+ emptyState="No harness found."
164
+ className="w-full sm:flex-1"
165
+ />
166
+ <Combobox
167
+ aria-label="Model"
168
+ options={modelOptions}
169
+ value={selectedModel}
170
+ onValueChange={(value) => {
171
+ if (value) setSelectedModel(value);
172
+ }}
173
+ placeholder="Select model..."
174
+ searchPlaceholder="Search models..."
175
+ emptyState="No model found."
176
+ className="w-full sm:flex-1"
177
+ />
178
+ </div>
179
+ )}
180
+
181
+ <div className="flex flex-col gap-8">
182
+ <CodeBlock data={data} className="h-auto min-h-0 rounded-8">
183
+ <CodeBlockHeader>
184
+ <CodeBlockFiles>
185
+ {(item) => (
186
+ <CodeBlockFilename value={item.filename}>
187
+ {item.filename}
188
+ </CodeBlockFilename>
189
+ )}
190
+ </CodeBlockFiles>
191
+ <CodeBlockCopyButton />
192
+ </CodeBlockHeader>
193
+ <CodeBlockBody>
194
+ {(item) => (
195
+ <CodeBlockItem value={item.filename}>
196
+ <CodeBlockContent
197
+ language="yaml"
198
+ syntaxHighlighting
199
+ highlightedLineRange={example.highlightedLineRange}
200
+ >
201
+ {item.code}
202
+ </CodeBlockContent>
203
+ </CodeBlockItem>
204
+ )}
205
+ </CodeBlockBody>
206
+ </CodeBlock>
207
+
208
+ <Text size="sm" className="text-foreground-neutral-muted">
209
+ Add this to a workflow file under{' '}
210
+ <Code as="span" variant="label">
211
+ .shipfox/workflows/
212
+ </Code>{' '}
213
+ in your repository to run it.
214
+ </Text>
215
+ </div>
216
+
217
+ <div className="flex flex-col gap-8">
218
+ <Text size="sm" bold>
219
+ Available models ({target.models.length})
220
+ </Text>
221
+ <ul className="rounded-8 border border-border-neutral-base">
222
+ {target.models.map((model) => (
223
+ <ModelProviderModelRow key={model.id} label={model.label} id={model.id} />
224
+ ))}
225
+ </ul>
226
+ </div>
227
+ </div>
228
+ </ModalBody>
229
+ <ModalFooter>
230
+ <Button type="button" onClick={() => handleOpenChange(false)}>
231
+ Done
232
+ </Button>
233
+ </ModalFooter>
234
+ </>
235
+ ) : null}
236
+ </ModalContent>
237
+ </Modal>
238
+ );
239
+ }
240
+
241
+ function selectInitialHarness(
242
+ compatibleIds: Harness[],
243
+ workspaceDefaultHarnessId: Harness | null | undefined,
244
+ ): Harness {
245
+ if (workspaceDefaultHarnessId && compatibleIds.includes(workspaceDefaultHarnessId)) {
246
+ return workspaceDefaultHarnessId;
247
+ }
248
+ if (compatibleIds.includes(DEFAULT_HARNESS)) return DEFAULT_HARNESS;
249
+ return compatibleIds[0] ?? DEFAULT_HARNESS;
250
+ }
251
+
252
+ function isHarness(value: string): value is Harness {
253
+ return listHarnessDescriptors().some((descriptor) => descriptor.id === value);
254
+ }
255
+
256
+ function ModelProviderModelRow({label, id}: {label: string; id: string}) {
257
+ const [copyState, setCopyState] = useState<CopyState>('idle');
258
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
259
+ const {copy} = useCopyToClipboard({
260
+ text: id,
261
+ onCopy: () => {
262
+ setTemporaryCopyState('copied');
263
+ },
264
+ });
265
+
266
+ useEffect(() => {
267
+ return () => {
268
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
269
+ };
270
+ }, []);
271
+
272
+ function setTemporaryCopyState(state: Exclude<CopyState, 'idle'>) {
273
+ setCopyState(state);
274
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
275
+ timeoutRef.current = setTimeout(() => setCopyState('idle'), 2500);
276
+ }
277
+
278
+ async function handleCopy() {
279
+ try {
280
+ await copy();
281
+ } catch {
282
+ setTemporaryCopyState('failed');
283
+ }
284
+ }
285
+
286
+ return (
287
+ <li className="border-b border-border-neutral-base last:border-b-0">
288
+ <Tooltip open={copyState === 'copied'}>
289
+ <TooltipTrigger asChild>
290
+ <button
291
+ type="button"
292
+ aria-label={`Copy ${label} model id ${id}`}
293
+ className="flex min-h-40 w-full min-w-0 flex-col items-start gap-2 px-12 py-8 text-left transition-colors hover:bg-background-components-hover focus-visible:shadow-border-interactive-with-active focus-visible:outline-none sm:flex-row sm:items-center sm:gap-8"
294
+ onClick={() => {
295
+ void handleCopy();
296
+ }}
297
+ >
298
+ <Text as="span" size="sm" bold className="max-w-full shrink-0 truncate sm:max-w-[48%]">
299
+ {label}
300
+ </Text>
301
+ <Code
302
+ as="span"
303
+ variant="label"
304
+ className="min-w-0 max-w-full truncate text-left text-foreground-neutral-muted sm:flex-1 sm:text-right"
305
+ >
306
+ {id}
307
+ </Code>
308
+ </button>
309
+ </TooltipTrigger>
310
+ <TooltipContent side="top" align="center">
311
+ Copied
312
+ </TooltipContent>
313
+ </Tooltip>
314
+ {copyState === 'copied' ? (
315
+ <span className="sr-only" role="status">
316
+ Copied {label} id
317
+ </span>
318
+ ) : null}
319
+ {copyState === 'failed' ? (
320
+ <span className="sr-only" role="status">
321
+ Could not copy {label} id
322
+ </span>
323
+ ) : null}
324
+ </li>
325
+ );
326
+ }
@@ -0,0 +1,36 @@
1
+ import type {
2
+ CustomModelProviderConfigDto,
3
+ ModelProviderCatalogEntryDto,
4
+ } from '@shipfox/api-agent-dto';
5
+
6
+ export interface ModelProviderUsageTarget {
7
+ id: string;
8
+ label: string;
9
+ isCustom: boolean;
10
+ models: Array<{id: string; label: string}>;
11
+ default_model: string | null;
12
+ }
13
+
14
+ export function usageTargetFromCatalogEntry(
15
+ entry: ModelProviderCatalogEntryDto,
16
+ ): ModelProviderUsageTarget {
17
+ return {
18
+ id: entry.id,
19
+ label: entry.label,
20
+ isCustom: false,
21
+ models: entry.models,
22
+ default_model: entry.default_model,
23
+ };
24
+ }
25
+
26
+ export function usageTargetFromCustomConfig(
27
+ config: CustomModelProviderConfigDto,
28
+ ): ModelProviderUsageTarget {
29
+ return {
30
+ id: config.provider_id,
31
+ label: config.display_name,
32
+ isCustom: true,
33
+ models: config.models,
34
+ default_model: config.default_model,
35
+ };
36
+ }
@@ -0,0 +1,345 @@
1
+ import type {
2
+ CustomModelProviderConfigDto,
3
+ ModelProviderCatalogEntryDto,
4
+ ModelProviderConfigDto,
5
+ SupportedModelProviderId,
6
+ } from '@shipfox/api-agent-dto';
7
+ import {configureApiClient} from '@shipfox/client-api';
8
+ import {Toaster} from '@shipfox/react-ui/toast';
9
+ import type {Meta, StoryObj} from '@storybook/react';
10
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
11
+ import {useMemo} from 'react';
12
+ import {screen, userEvent, within} from 'storybook/test';
13
+ import {WorkspaceModelProvidersSection} from './model-providers-section.js';
14
+
15
+ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
16
+
17
+ type Scenario =
18
+ | 'mixed'
19
+ | 'empty-configured'
20
+ | 'all-configured'
21
+ | 'custom-configured'
22
+ | 'loading'
23
+ | 'configs-error'
24
+ | 'catalog-error'
25
+ | 'long-names';
26
+
27
+ interface ModelProvidersStoryProps {
28
+ scenario: Scenario;
29
+ }
30
+
31
+ const CATALOG: ModelProviderCatalogEntryDto[] = [
32
+ providerEntry({
33
+ id: 'anthropic',
34
+ label: 'Anthropic',
35
+ default_model: 'claude-opus-4-8',
36
+ models: [
37
+ {id: 'claude-opus-4-8', label: 'Claude Opus 4.8'},
38
+ {id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5'},
39
+ ],
40
+ }),
41
+ providerEntry({
42
+ id: 'openai',
43
+ label: 'OpenAI',
44
+ default_model: 'gpt-5.5-pro',
45
+ models: [
46
+ {id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'},
47
+ {id: 'gpt-5-mini', label: 'GPT-5 Mini'},
48
+ ],
49
+ }),
50
+ providerEntry({
51
+ id: 'xai',
52
+ label: 'xAI',
53
+ default_model: 'grok-code-fast-1',
54
+ models: [{id: 'grok-code-fast-1', label: 'Grok Code Fast 1'}],
55
+ }),
56
+ providerEntry({
57
+ id: 'openrouter',
58
+ label: 'OpenRouter',
59
+ default_model: 'openrouter/auto',
60
+ models: [{id: 'openrouter/auto', label: 'OpenRouter Auto'}],
61
+ }),
62
+ providerEntry({
63
+ id: 'google',
64
+ label: 'Google Gemini',
65
+ default_model: 'gemini-3-pro',
66
+ models: [{id: 'gemini-3-pro', label: 'Gemini 3 Pro'}],
67
+ }),
68
+ providerEntry({
69
+ id: 'mistral',
70
+ label: 'Mistral AI',
71
+ default_model: 'codestral-latest',
72
+ models: [{id: 'codestral-latest', label: 'Codestral Latest'}],
73
+ }),
74
+ providerEntry({
75
+ id: 'groq',
76
+ label: 'Groq',
77
+ default_model: 'llama-4-fast',
78
+ models: [{id: 'llama-4-fast', label: 'Llama 4 Fast'}],
79
+ }),
80
+ providerEntry({
81
+ id: 'deepseek',
82
+ label: 'DeepSeek',
83
+ default_model: 'deepseek-coder',
84
+ models: [{id: 'deepseek-coder', label: 'DeepSeek Coder'}],
85
+ }),
86
+ providerEntry({
87
+ id: 'moonshotai',
88
+ label: 'Moonshot AI',
89
+ default_model: 'kimi-k2',
90
+ models: [{id: 'kimi-k2', label: 'Kimi K2'}],
91
+ }),
92
+ providerEntry({
93
+ id: 'azure-openai-responses',
94
+ label: 'Azure OpenAI Responses',
95
+ default_model: 'gpt-5.5-pro',
96
+ models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
97
+ }),
98
+ providerEntry({
99
+ id: 'amazon-bedrock',
100
+ label: 'Amazon Bedrock',
101
+ support_status: 'unsupported',
102
+ credential_fields: [],
103
+ unsupported_reason: 'AWS cloud credentials are not supported yet.',
104
+ models: [],
105
+ }),
106
+ ];
107
+
108
+ function ModelProvidersStory({scenario}: ModelProvidersStoryProps) {
109
+ configureApiClient({
110
+ baseUrl: 'https://api.example.test',
111
+ fetchImpl: fetchForScenario(scenario),
112
+ });
113
+
114
+ const queryClient = useMemo(
115
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
116
+ [],
117
+ );
118
+
119
+ return (
120
+ <QueryClientProvider client={queryClient}>
121
+ <div className="mx-auto w-full max-w-[760px] bg-background-neutral-background p-24">
122
+ <WorkspaceModelProvidersSection workspaceId={WORKSPACE_ID} />
123
+ </div>
124
+ <Toaster />
125
+ </QueryClientProvider>
126
+ );
127
+ }
128
+
129
+ const meta = {
130
+ title: 'Agent/ProviderSettings',
131
+ component: ModelProvidersStory,
132
+ parameters: {layout: 'fullscreen'},
133
+ args: {scenario: 'mixed'},
134
+ } satisfies Meta<typeof ModelProvidersStory>;
135
+
136
+ export default meta;
137
+ type Story = StoryObj<typeof meta>;
138
+
139
+ export const Playground: Story = {};
140
+
141
+ export const EmptyConfigured: Story = {
142
+ args: {scenario: 'empty-configured'},
143
+ };
144
+
145
+ export const AllConfigured: Story = {
146
+ args: {scenario: 'all-configured'},
147
+ };
148
+
149
+ export const CustomConfigured: Story = {
150
+ args: {scenario: 'custom-configured'},
151
+ };
152
+
153
+ export const Loading: Story = {
154
+ args: {scenario: 'loading'},
155
+ };
156
+
157
+ export const ConfigsError: Story = {
158
+ args: {scenario: 'configs-error'},
159
+ };
160
+
161
+ export const CatalogError: Story = {
162
+ args: {scenario: 'catalog-error'},
163
+ };
164
+
165
+ export const LongNames: Story = {
166
+ args: {scenario: 'long-names'},
167
+ };
168
+
169
+ export const FilteredAvailableProviders: Story = {
170
+ args: {scenario: 'empty-configured'},
171
+ play: async ({canvasElement}) => {
172
+ const canvas = within(canvasElement);
173
+ await userEvent.type(
174
+ await canvas.findByRole('searchbox', {name: 'Search providers'}),
175
+ 'openrouter',
176
+ );
177
+ await canvas.findByRole('button', {name: 'Configure OpenRouter'});
178
+ },
179
+ };
180
+
181
+ export const NoMatchingAvailableProviders: Story = {
182
+ args: {scenario: 'empty-configured'},
183
+ play: async ({canvasElement}) => {
184
+ const canvas = within(canvasElement);
185
+ await userEvent.type(
186
+ await canvas.findByRole('searchbox', {name: 'Search providers'}),
187
+ 'not-a-provider',
188
+ );
189
+ await canvas.findByRole('button', {name: 'Clear search'});
190
+ },
191
+ };
192
+
193
+ export const ConfigureModalOpen: Story = {
194
+ args: {scenario: 'empty-configured'},
195
+ play: async ({canvasElement}) => {
196
+ const canvas = within(canvasElement);
197
+ await userEvent.click(await canvas.findByRole('button', {name: 'Configure Anthropic'}));
198
+ await screen.findByLabelText('API key');
199
+ },
200
+ };
201
+
202
+ export const CustomProviderModalOpen: Story = {
203
+ args: {scenario: 'empty-configured'},
204
+ play: async ({canvasElement}) => {
205
+ const canvas = within(canvasElement);
206
+ await userEvent.click(await canvas.findByRole('button', {name: 'Configure custom provider'}));
207
+ await screen.findByRole('dialog', {name: 'Add custom provider'});
208
+ },
209
+ };
210
+
211
+ export const WorkflowExampleModalOpen: Story = {
212
+ args: {scenario: 'mixed'},
213
+ play: async ({canvasElement}) => {
214
+ const canvas = within(canvasElement);
215
+ await userEvent.click(
216
+ await canvas.findByRole('button', {name: 'Open Anthropic provider actions'}),
217
+ );
218
+ await userEvent.click(await screen.findByRole('menuitem', {name: 'View workflow example'}));
219
+ await screen.findByRole('dialog', {name: 'Use Anthropic in a workflow'});
220
+ },
221
+ };
222
+
223
+ function fetchForScenario(scenario: Scenario): typeof fetch {
224
+ return (input) => {
225
+ const url = requestUrl(input);
226
+ if (scenario === 'loading') return new Promise<Response>(() => undefined);
227
+ if (url.pathname.endsWith('/agent/model-provider-catalog')) {
228
+ if (scenario === 'catalog-error') return Promise.resolve(errorResponse());
229
+ return Promise.resolve(jsonResponse({providers: catalogForScenario(scenario)}));
230
+ }
231
+ if (url.pathname.endsWith('/agent/model-providers')) {
232
+ if (scenario === 'configs-error') return Promise.resolve(errorResponse());
233
+ return Promise.resolve(jsonResponse(configsForScenario(scenario)));
234
+ }
235
+ return Promise.resolve(jsonResponse({}, {status: 404}));
236
+ };
237
+ }
238
+
239
+ function catalogForScenario(scenario: Scenario): ModelProviderCatalogEntryDto[] {
240
+ if (scenario !== 'long-names') return CATALOG;
241
+ return [
242
+ providerEntry({
243
+ id: 'anthropic',
244
+ label: 'Anthropic Enterprise Production Claude Provider With Long Label',
245
+ default_model: 'claude-opus-4-8',
246
+ }),
247
+ providerEntry({
248
+ id: 'openai',
249
+ label: 'OpenAI Platform Shared Workspace Credentials With Long Label',
250
+ default_model: 'gpt-5.5-pro',
251
+ models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
252
+ }),
253
+ ];
254
+ }
255
+
256
+ function configsForScenario(scenario: Scenario) {
257
+ if (scenario === 'empty-configured' || scenario === 'configs-error') {
258
+ return {configs: [], default_provider_id: null};
259
+ }
260
+ if (scenario === 'all-configured') {
261
+ return {
262
+ configs: CATALOG.filter((entry) => entry.support_status === 'supported').map((entry) =>
263
+ providerConfig({
264
+ provider_id: entry.id as SupportedModelProviderId,
265
+ }),
266
+ ),
267
+ default_provider_id: 'anthropic',
268
+ };
269
+ }
270
+ if (scenario === 'custom-configured') {
271
+ return {
272
+ configs: [providerConfig({provider_id: 'anthropic'}), customProviderConfig()],
273
+ default_provider_id: 'local-vllm',
274
+ };
275
+ }
276
+ return {
277
+ configs: [providerConfig({provider_id: 'anthropic'})],
278
+ default_provider_id: 'anthropic',
279
+ };
280
+ }
281
+
282
+ function customProviderConfig(): CustomModelProviderConfigDto {
283
+ return {
284
+ kind: 'custom',
285
+ provider_id: 'local-vllm',
286
+ display_name: 'Local vLLM',
287
+ api: 'openai-completions',
288
+ base_url: 'http://localhost:8000/v1',
289
+ headers: [{name: 'x-region', value: 'us'}],
290
+ secret_header_names: ['authorization'],
291
+ models: [
292
+ {id: 'llama-3.1', label: 'Llama 3.1'},
293
+ {id: 'qwen-coder', label: 'Qwen Coder'},
294
+ ],
295
+ default_model: 'llama-3.1',
296
+ created_at: '2026-05-08T00:00:00.000Z',
297
+ updated_at: '2026-05-08T00:00:00.000Z',
298
+ };
299
+ }
300
+
301
+ function providerEntry(
302
+ overrides: Partial<ModelProviderCatalogEntryDto> = {},
303
+ ): ModelProviderCatalogEntryDto {
304
+ return {
305
+ id: 'anthropic',
306
+ label: 'Anthropic',
307
+ support_status: 'supported',
308
+ default_model: null,
309
+ credential_fields: [{key: 'api_key', label: 'API key', secret: true}],
310
+ unsupported_reason: null,
311
+ models: [{id: 'claude-opus-4-8', label: 'Claude Opus 4.8'}],
312
+ ...overrides,
313
+ };
314
+ }
315
+
316
+ function providerConfig(
317
+ overrides: Partial<Omit<ModelProviderConfigDto, 'kind'>> = {},
318
+ ): ModelProviderConfigDto {
319
+ return {
320
+ kind: 'builtin',
321
+ provider_id: 'anthropic',
322
+ default_model: null,
323
+ created_at: '2026-05-08T00:00:00.000Z',
324
+ updated_at: '2026-05-08T00:00:00.000Z',
325
+ ...overrides,
326
+ };
327
+ }
328
+
329
+ function requestUrl(input: RequestInfo | URL): URL {
330
+ if (typeof input === 'string') return new URL(input);
331
+ if (input instanceof URL) return input;
332
+ return new URL(input.url);
333
+ }
334
+
335
+ function jsonResponse(body: unknown, init: ResponseInit = {}) {
336
+ return new Response(JSON.stringify(body), {
337
+ status: 200,
338
+ headers: {'content-type': 'application/json'},
339
+ ...init,
340
+ });
341
+ }
342
+
343
+ function errorResponse() {
344
+ return jsonResponse({code: 'server-error'}, {status: 500, statusText: 'Server error'});
345
+ }