@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,245 @@
1
+ import {
2
+ DEFAULT_HARNESS,
3
+ type Harness,
4
+ type HarnessDescriptor,
5
+ listHarnessDescriptors,
6
+ } from '@shipfox/api-agent-dto';
7
+ import {QueryLoadError} from '@shipfox/client-ui';
8
+ import {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 {Icon} from '@shipfox/react-ui/icon';
17
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
18
+ import {toast} from '@shipfox/react-ui/toast';
19
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
20
+ import {Header, Text} from '@shipfox/react-ui/typography';
21
+ import {cn} from '@shipfox/react-ui/utils';
22
+ import {useRef, useState} from 'react';
23
+ import {
24
+ useModelProviderConfigsQuery,
25
+ useSetDefaultHarnessMutation,
26
+ } from '#hooks/api/model-providers.js';
27
+ import {modelProviderConfigErrorToFormError} from './form-errors.js';
28
+ import {isHarnessAvailable} from './harness-availability.js';
29
+
30
+ const SURFACE_CLASS =
31
+ 'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
32
+
33
+ export function WorkspaceHarnessesSection({workspaceId}: {workspaceId: string}) {
34
+ const configsQuery = useModelProviderConfigsQuery(workspaceId);
35
+ const setDefaultHarness = useSetDefaultHarnessMutation();
36
+ const activeWorkspaceIdRef = useRef(workspaceId);
37
+ const defaultRequestSeqRef = useRef(0);
38
+ const activeDefaultRequestRef = useRef<{workspaceId: string; id: number} | null>(null);
39
+ const [pendingDefaultHarness, setPendingDefaultHarness] = useState<{
40
+ workspaceId: string;
41
+ harnessId: Harness;
42
+ } | null>(null);
43
+ const [defaultError, setDefaultError] = useState<
44
+ {workspaceId: string; harnessId: Harness; message: string} | undefined
45
+ >();
46
+ const configs = configsQuery.data?.configs ?? [];
47
+ const defaultHarnessId = configsQuery.data?.default_harness_id ?? DEFAULT_HARNESS;
48
+ activeWorkspaceIdRef.current = workspaceId;
49
+
50
+ function isActiveDefaultRequest(request: {workspaceId: string; id: number}) {
51
+ const activeRequest = activeDefaultRequestRef.current;
52
+ return (
53
+ activeWorkspaceIdRef.current === request.workspaceId &&
54
+ activeRequest?.workspaceId === request.workspaceId &&
55
+ activeRequest.id === request.id
56
+ );
57
+ }
58
+
59
+ async function handleSetDefault(harness: HarnessDescriptor) {
60
+ if (activeDefaultRequestRef.current?.workspaceId === workspaceId) return;
61
+
62
+ const request = {workspaceId, id: defaultRequestSeqRef.current + 1};
63
+ defaultRequestSeqRef.current = request.id;
64
+ activeDefaultRequestRef.current = request;
65
+ setPendingDefaultHarness({workspaceId, harnessId: harness.id});
66
+ setDefaultError(undefined);
67
+ try {
68
+ await setDefaultHarness.mutateAsync({
69
+ workspaceId,
70
+ body: {harness_id: harness.id},
71
+ });
72
+ if (!isActiveDefaultRequest(request)) return;
73
+ toast.success(`${harness.label} is now the default harness`);
74
+ } catch (error) {
75
+ if (!isActiveDefaultRequest(request)) return;
76
+ const mapped = modelProviderConfigErrorToFormError(error);
77
+ setDefaultError({
78
+ workspaceId,
79
+ harnessId: harness.id,
80
+ message: mapped.message || 'Could not save default harness. Try again.',
81
+ });
82
+ } finally {
83
+ if (isActiveDefaultRequest(request)) {
84
+ activeDefaultRequestRef.current = null;
85
+ setPendingDefaultHarness(null);
86
+ }
87
+ }
88
+ }
89
+
90
+ return (
91
+ <section className="flex flex-col gap-16" aria-label="Harnesses">
92
+ <div className="flex flex-col gap-4">
93
+ <Header variant="h3">Harnesses</Header>
94
+ <Text size="sm" className="text-foreground-neutral-muted">
95
+ Harnesses available to run agent steps in this workspace.
96
+ </Text>
97
+ </div>
98
+
99
+ {configsQuery.isPending ? <HarnessRowsSkeleton /> : null}
100
+
101
+ {configsQuery.isError && configsQuery.data === undefined ? (
102
+ <div className={cn(SURFACE_CLASS, 'px-16')}>
103
+ <QueryLoadError query={configsQuery} subject="harnesses" />
104
+ </div>
105
+ ) : null}
106
+
107
+ {configsQuery.data !== undefined ? (
108
+ <ul className={cn('divide-y divide-border-neutral-base', SURFACE_CLASS)}>
109
+ {listHarnessDescriptors().map((harness) => (
110
+ <HarnessRow
111
+ key={harness.id}
112
+ harness={harness}
113
+ isDefault={harness.id === defaultHarnessId}
114
+ isAvailable={isHarnessAvailable(harness, configs)}
115
+ isSettingDefault={pendingDefaultHarness?.workspaceId === workspaceId}
116
+ defaultError={
117
+ defaultError?.workspaceId === workspaceId && defaultError.harnessId === harness.id
118
+ ? defaultError.message
119
+ : undefined
120
+ }
121
+ onSetDefault={handleSetDefault}
122
+ />
123
+ ))}
124
+ </ul>
125
+ ) : null}
126
+ </section>
127
+ );
128
+ }
129
+
130
+ function HarnessRow({
131
+ harness,
132
+ isDefault,
133
+ isAvailable,
134
+ isSettingDefault,
135
+ defaultError,
136
+ onSetDefault,
137
+ }: {
138
+ harness: HarnessDescriptor;
139
+ isDefault: boolean;
140
+ isAvailable: boolean;
141
+ isSettingDefault: boolean;
142
+ defaultError: string | undefined;
143
+ onSetDefault: (harness: HarnessDescriptor) => void;
144
+ }) {
145
+ const unavailableCopy = harnessUnavailableCopy(isDefault);
146
+
147
+ return (
148
+ <li className="flex flex-col gap-10 px-16 py-12 transition-colors hover:bg-background-components-hover">
149
+ <div className="flex items-center justify-between gap-12">
150
+ <div className="flex min-w-0 items-center gap-8">
151
+ {isDefault ? (
152
+ <>
153
+ <Tooltip>
154
+ <TooltipTrigger asChild>
155
+ <span className="inline-flex size-16 shrink-0 items-center justify-center">
156
+ <Icon
157
+ name="starLine"
158
+ className="size-16 text-foreground-neutral-muted"
159
+ aria-hidden
160
+ />
161
+ </span>
162
+ </TooltipTrigger>
163
+ <TooltipContent>Default harness</TooltipContent>
164
+ </Tooltip>
165
+ <span className="sr-only">Default harness</span>
166
+ </>
167
+ ) : null}
168
+ <Text size="md" bold className="truncate">
169
+ {harness.label}
170
+ </Text>
171
+ {!isAvailable ? (
172
+ <>
173
+ <Tooltip>
174
+ <TooltipTrigger asChild>
175
+ <span className="inline-flex size-16 shrink-0 items-center justify-center">
176
+ <Icon
177
+ name="errorWarningLine"
178
+ className="size-16 text-foreground-warning-base"
179
+ aria-hidden
180
+ />
181
+ </span>
182
+ </TooltipTrigger>
183
+ <TooltipContent>{unavailableCopy}</TooltipContent>
184
+ </Tooltip>
185
+ <span className="sr-only">{unavailableCopy}</span>
186
+ </>
187
+ ) : null}
188
+ </div>
189
+ {!isDefault ? (
190
+ <DropdownMenu>
191
+ <DropdownMenuTrigger asChild>
192
+ <IconButton
193
+ size="sm"
194
+ variant="transparent"
195
+ icon="more2Line"
196
+ disabled={isSettingDefault}
197
+ aria-label={`Open ${harness.label} harness actions`}
198
+ />
199
+ </DropdownMenuTrigger>
200
+ <DropdownMenuContent align="end">
201
+ <DropdownMenuItem
202
+ icon="starLine"
203
+ disabled={isSettingDefault || !isAvailable}
204
+ onSelect={() => {
205
+ onSetDefault(harness);
206
+ }}
207
+ >
208
+ Set as default
209
+ </DropdownMenuItem>
210
+ </DropdownMenuContent>
211
+ </DropdownMenu>
212
+ ) : null}
213
+ </div>
214
+ {defaultError ? (
215
+ <Callout role="alert" type="error">
216
+ <Text size="sm">{defaultError}</Text>
217
+ </Callout>
218
+ ) : null}
219
+ </li>
220
+ );
221
+ }
222
+
223
+ function harnessUnavailableCopy(isDefault: boolean): string {
224
+ const base = 'Configure a compatible model provider to use this harness.';
225
+ if (!isDefault) return base;
226
+ return `${base} The stored default harness cannot currently run in this workspace.`;
227
+ }
228
+
229
+ function HarnessRowsSkeleton() {
230
+ return (
231
+ <ul
232
+ role="status"
233
+ aria-label="Loading harnesses"
234
+ className={cn('divide-y divide-border-neutral-base', SURFACE_CLASS)}
235
+ >
236
+ {[0, 1].map((row) => (
237
+ <li key={row} className="flex items-center gap-12 px-16 py-12">
238
+ <Skeleton className="size-16 shrink-0" />
239
+ <Skeleton className="h-16 w-120" />
240
+ <Skeleton className="ml-auto size-28 shrink-0" />
241
+ </li>
242
+ ))}
243
+ </ul>
244
+ );
245
+ }
@@ -0,0 +1,106 @@
1
+ import type {
2
+ CustomModelProviderConfigDto,
3
+ ModelProviderCatalogEntryDto,
4
+ } from '@shipfox/api-agent-dto';
5
+ import type {Meta, StoryObj} from '@storybook/react';
6
+ import {useState} from 'react';
7
+ import {ModelProviderUsageModal} from './model-provider-usage-modal.js';
8
+ import {
9
+ usageTargetFromCatalogEntry,
10
+ usageTargetFromCustomConfig,
11
+ } from './model-provider-usage-target.js';
12
+
13
+ interface ModelProviderUsageModalStoryProps {
14
+ variant: 'anthropic' | 'custom' | 'long-list';
15
+ }
16
+
17
+ function ModelProviderUsageModalStory({variant}: ModelProviderUsageModalStoryProps) {
18
+ const [open, setOpen] = useState(true);
19
+ const entry = variant === 'long-list' ? longListEntry() : anthropicEntry();
20
+ const target =
21
+ variant === 'custom'
22
+ ? usageTargetFromCustomConfig(customProviderConfig())
23
+ : usageTargetFromCatalogEntry(entry);
24
+
25
+ return (
26
+ <div className="min-h-screen bg-background-neutral-background p-24">
27
+ <ModelProviderUsageModal
28
+ target={target}
29
+ initialModel={target.default_model}
30
+ workspaceDefaultHarnessId="claude"
31
+ open={open}
32
+ onOpenChange={setOpen}
33
+ />
34
+ </div>
35
+ );
36
+ }
37
+
38
+ const meta = {
39
+ title: 'Agent/ProviderUsageModal',
40
+ component: ModelProviderUsageModalStory,
41
+ parameters: {layout: 'fullscreen'},
42
+ args: {variant: 'anthropic'},
43
+ } satisfies Meta<typeof ModelProviderUsageModalStory>;
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof meta>;
47
+
48
+ export const Playground: Story = {};
49
+
50
+ export const LongModelList: Story = {
51
+ args: {variant: 'long-list'},
52
+ };
53
+
54
+ export const CustomProvider: Story = {
55
+ args: {variant: 'custom'},
56
+ };
57
+
58
+ function anthropicEntry(): ModelProviderCatalogEntryDto {
59
+ return {
60
+ id: 'anthropic',
61
+ label: 'Anthropic',
62
+ support_status: 'supported',
63
+ default_model: 'claude-opus-4-8',
64
+ credential_fields: [{key: 'api_key', label: 'API key', secret: true}],
65
+ unsupported_reason: null,
66
+ models: [
67
+ {id: 'claude-opus-4-8', label: 'Claude Opus 4.8'},
68
+ {id: 'claude-sonnet-4-8', label: 'Claude Sonnet 4.8'},
69
+ {id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5'},
70
+ ],
71
+ };
72
+ }
73
+
74
+ function longListEntry(): ModelProviderCatalogEntryDto {
75
+ return {
76
+ id: 'cloudflare-workers-ai',
77
+ label: 'Cloudflare Workers AI',
78
+ support_status: 'supported',
79
+ default_model: '@cf/moonshotai/kimi-k2.7-code',
80
+ credential_fields: [{key: 'api_token', label: 'API token', secret: true}],
81
+ unsupported_reason: null,
82
+ models: Array.from({length: 56}, (_, index) => ({
83
+ id:
84
+ index === 0
85
+ ? '@cf/moonshotai/kimi-k2.7-code'
86
+ : `provider/research-lab/model-family-${String(index).padStart(2, '0')}-large-context`,
87
+ label: index === 0 ? 'Kimi K2.7 Code' : `Research Model ${index}`,
88
+ })),
89
+ };
90
+ }
91
+
92
+ function customProviderConfig(): CustomModelProviderConfigDto {
93
+ return {
94
+ kind: 'custom',
95
+ provider_id: 'local-vllm',
96
+ display_name: 'Local vLLM',
97
+ api: 'openai-completions',
98
+ base_url: 'http://localhost:8000/v1',
99
+ headers: [],
100
+ secret_header_names: [],
101
+ models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
102
+ default_model: 'llama-3.1',
103
+ created_at: '2026-05-08T00:00:00.000Z',
104
+ updated_at: '2026-05-08T00:00:00.000Z',
105
+ };
106
+ }
@@ -0,0 +1,210 @@
1
+ import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import {customModelProviderConfig, modelProviderEntry} from '#test/fixtures/model-providers.js';
4
+ import {ModelProviderUsageModal} from './model-provider-usage-modal.js';
5
+ import {
6
+ usageTargetFromCatalogEntry,
7
+ usageTargetFromCustomConfig,
8
+ } from './model-provider-usage-target.js';
9
+
10
+ const CLAUDE_MODEL_ROW_NAME = 'Copy Claude Opus 4.8 model id claude-opus-4-8';
11
+ const KIMI_MODEL_ROW_NAME = 'Copy Kimi K2.7 Code model id @cf/moonshotai/kimi-k2.7-code';
12
+
13
+ function renderUsageModal() {
14
+ const onOpenChange = vi.fn();
15
+ const entry = modelProviderEntry({
16
+ default_model: 'claude-opus-4-8',
17
+ models: [
18
+ {id: 'claude-opus-4-8', label: 'Claude Opus 4.8'},
19
+ {id: '@cf/moonshotai/kimi-k2.7-code', label: 'Kimi K2.7 Code'},
20
+ ],
21
+ });
22
+
23
+ render(
24
+ <ModelProviderUsageModal
25
+ target={usageTargetFromCatalogEntry(entry)}
26
+ initialModel="claude-opus-4-8"
27
+ open
28
+ onOpenChange={onOpenChange}
29
+ />,
30
+ );
31
+
32
+ return {entry, onOpenChange};
33
+ }
34
+
35
+ describe('ModelProviderUsageModal', () => {
36
+ test('changes the selected model in the workflow example', async () => {
37
+ renderUsageModal();
38
+ await waitFor(() =>
39
+ expect(screen.getByRole('dialog', {name: 'Use Anthropic in a workflow'})).toHaveTextContent(
40
+ 'harness: pi',
41
+ ),
42
+ );
43
+ await waitFor(() =>
44
+ expect(screen.getByRole('dialog', {name: 'Use Anthropic in a workflow'})).toHaveTextContent(
45
+ 'model: claude-opus-4-8',
46
+ ),
47
+ );
48
+
49
+ fireEvent.click(screen.getByRole('button', {name: 'Model'}));
50
+ fireEvent.click(await screen.findByRole('option', {name: 'Kimi K2.7 Code'}));
51
+
52
+ await waitFor(() =>
53
+ expect(screen.getByRole('dialog', {name: 'Use Anthropic in a workflow'})).toHaveTextContent(
54
+ "model: '@cf/moonshotai/kimi-k2.7-code'",
55
+ ),
56
+ );
57
+ expect(screen.getByRole('dialog', {name: 'Use Anthropic in a workflow'})).not.toHaveTextContent(
58
+ 'model: claude-opus-4-8',
59
+ );
60
+ }, 10_000);
61
+
62
+ test('changes the selected harness in the workflow example', async () => {
63
+ renderUsageModal();
64
+ await waitFor(() =>
65
+ expect(screen.getByRole('dialog', {name: 'Use Anthropic in a workflow'})).toHaveTextContent(
66
+ 'harness: pi',
67
+ ),
68
+ );
69
+
70
+ fireEvent.click(screen.getByRole('button', {name: 'Harness'}));
71
+ fireEvent.click(await screen.findByRole('option', {name: 'Claude'}));
72
+
73
+ await waitFor(() =>
74
+ expect(screen.getByRole('dialog', {name: 'Use Anthropic in a workflow'})).toHaveTextContent(
75
+ 'harness: claude',
76
+ ),
77
+ );
78
+ });
79
+
80
+ test('uses the workspace default harness when it is compatible', async () => {
81
+ const onOpenChange = vi.fn();
82
+ const entry = modelProviderEntry();
83
+
84
+ render(
85
+ <ModelProviderUsageModal
86
+ target={usageTargetFromCatalogEntry(entry)}
87
+ initialModel="claude-opus-4-8"
88
+ workspaceDefaultHarnessId="claude"
89
+ open
90
+ onOpenChange={onOpenChange}
91
+ />,
92
+ );
93
+
94
+ await waitFor(() =>
95
+ expect(screen.getByRole('dialog', {name: 'Use Anthropic in a workflow'})).toHaveTextContent(
96
+ 'harness: claude',
97
+ ),
98
+ );
99
+ });
100
+
101
+ test('clamps the selected harness when the target changes', async () => {
102
+ const onOpenChange = vi.fn();
103
+ const anthropic = modelProviderEntry();
104
+ const openai = modelProviderEntry({
105
+ id: 'openai',
106
+ label: 'OpenAI',
107
+ models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
108
+ });
109
+ const {rerender} = render(
110
+ <ModelProviderUsageModal
111
+ target={usageTargetFromCatalogEntry(anthropic)}
112
+ initialModel="claude-opus-4-8"
113
+ workspaceDefaultHarnessId="claude"
114
+ open
115
+ onOpenChange={onOpenChange}
116
+ />,
117
+ );
118
+ await waitFor(() =>
119
+ expect(screen.getByRole('dialog', {name: 'Use Anthropic in a workflow'})).toHaveTextContent(
120
+ 'harness: claude',
121
+ ),
122
+ );
123
+
124
+ rerender(
125
+ <ModelProviderUsageModal
126
+ target={usageTargetFromCatalogEntry(openai)}
127
+ initialModel="gpt-5.5-pro"
128
+ workspaceDefaultHarnessId="claude"
129
+ open
130
+ onOpenChange={onOpenChange}
131
+ />,
132
+ );
133
+
134
+ await waitFor(() =>
135
+ expect(screen.getByRole('dialog', {name: 'Use OpenAI in a workflow'})).toHaveTextContent(
136
+ 'harness: pi',
137
+ ),
138
+ );
139
+ expect(screen.queryByRole('button', {name: 'Harness'})).not.toBeInTheDocument();
140
+ });
141
+
142
+ test('renders a static pi harness line for custom providers', async () => {
143
+ const onOpenChange = vi.fn();
144
+
145
+ render(
146
+ <ModelProviderUsageModal
147
+ target={usageTargetFromCustomConfig(customModelProviderConfig())}
148
+ initialModel="custom-model"
149
+ workspaceDefaultHarnessId="claude"
150
+ open
151
+ onOpenChange={onOpenChange}
152
+ />,
153
+ );
154
+
155
+ await waitFor(() =>
156
+ expect(
157
+ screen.getByRole('dialog', {name: 'Use OpenAI Compatible in a workflow'}),
158
+ ).toHaveTextContent('harness: pi'),
159
+ );
160
+ expect(screen.getByText('Harness')).toBeVisible();
161
+ expect(screen.getAllByText('pi').length).toBeGreaterThan(0);
162
+ expect(screen.queryByRole('button', {name: 'Harness'})).not.toBeInTheDocument();
163
+ });
164
+
165
+ test('renders reference models as clickable rows with inline ids', async () => {
166
+ renderUsageModal();
167
+
168
+ const list = await screen.findByRole('list');
169
+ expect(within(list).getByText('Claude Opus 4.8')).toBeVisible();
170
+ expect(within(list).getByText('@cf/moonshotai/kimi-k2.7-code')).toBeVisible();
171
+ expect(within(list).getByRole('button', {name: KIMI_MODEL_ROW_NAME})).toBeVisible();
172
+ });
173
+
174
+ test('copies the full model id', async () => {
175
+ const user = userEvent.setup();
176
+ const writeText = vi.fn().mockResolvedValue(undefined);
177
+ Object.defineProperty(navigator, 'clipboard', {
178
+ configurable: true,
179
+ value: {writeText},
180
+ });
181
+
182
+ renderUsageModal();
183
+ await user.click(await screen.findByRole('button', {name: KIMI_MODEL_ROW_NAME}));
184
+
185
+ expect(writeText).toHaveBeenCalledWith('@cf/moonshotai/kimi-k2.7-code');
186
+ expect(await screen.findAllByText('Copied')).toHaveLength(2);
187
+ expect(await screen.findByRole('status')).toHaveTextContent('Copied Kimi K2.7 Code id');
188
+ });
189
+
190
+ test('shows failed copy feedback', async () => {
191
+ const user = userEvent.setup();
192
+ const writeText = vi.fn().mockRejectedValue(new Error('denied'));
193
+ Object.defineProperty(navigator, 'clipboard', {
194
+ configurable: true,
195
+ value: {writeText},
196
+ });
197
+ Object.defineProperty(document, 'execCommand', {
198
+ configurable: true,
199
+ value: vi.fn().mockReturnValue(false),
200
+ });
201
+
202
+ renderUsageModal();
203
+ await user.click(await screen.findByRole('button', {name: CLAUDE_MODEL_ROW_NAME}));
204
+
205
+ await waitFor(() => expect(document.execCommand).toHaveBeenCalledWith('copy'));
206
+ expect(await screen.findByRole('status')).toHaveTextContent(
207
+ 'Could not copy Claude Opus 4.8 id',
208
+ );
209
+ });
210
+ });