@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.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +4 -0
- package/.storybook/preview.tsx +56 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +93 -0
- package/LICENSE +21 -0
- package/dist/components/add-custom-provider-card.d.ts +4 -0
- package/dist/components/add-custom-provider-card.d.ts.map +1 -0
- package/dist/components/add-custom-provider-card.js +41 -0
- package/dist/components/add-custom-provider-card.js.map +1 -0
- package/dist/components/agent-workflow-example.d.ts +11 -0
- package/dist/components/agent-workflow-example.d.ts.map +1 -0
- package/dist/components/agent-workflow-example.js +38 -0
- package/dist/components/agent-workflow-example.js.map +1 -0
- package/dist/components/available-provider-card.d.ts +6 -0
- package/dist/components/available-provider-card.d.ts.map +1 -0
- package/dist/components/available-provider-card.js +41 -0
- package/dist/components/available-provider-card.js.map +1 -0
- package/dist/components/available-providers-grid.d.ts +10 -0
- package/dist/components/available-providers-grid.d.ts.map +1 -0
- package/dist/components/available-providers-grid.js +89 -0
- package/dist/components/available-providers-grid.js.map +1 -0
- package/dist/components/change-default-model-form.d.ts +9 -0
- package/dist/components/change-default-model-form.d.ts.map +1 -0
- package/dist/components/change-default-model-form.js +98 -0
- package/dist/components/change-default-model-form.js.map +1 -0
- package/dist/components/custom-model-provider-api-options.d.ts +7 -0
- package/dist/components/custom-model-provider-api-options.d.ts.map +1 -0
- package/dist/components/custom-model-provider-api-options.js +23 -0
- package/dist/components/custom-model-provider-api-options.js.map +1 -0
- package/dist/components/custom-model-provider-form.d.ts +8 -0
- package/dist/components/custom-model-provider-form.d.ts.map +1 -0
- package/dist/components/custom-model-provider-form.js +667 -0
- package/dist/components/custom-model-provider-form.js.map +1 -0
- package/dist/components/custom-model-provider-form.stories.d.ts +20 -0
- package/dist/components/custom-model-provider-form.stories.d.ts.map +1 -0
- package/dist/components/custom-model-provider-form.stories.js +104 -0
- package/dist/components/custom-model-provider-form.stories.js.map +1 -0
- package/dist/components/custom-model-provider-payload.d.ts +39 -0
- package/dist/components/custom-model-provider-payload.d.ts.map +1 -0
- package/dist/components/custom-model-provider-payload.js +203 -0
- package/dist/components/custom-model-provider-payload.js.map +1 -0
- package/dist/components/custom-model-provider-slug.d.ts +3 -0
- package/dist/components/custom-model-provider-slug.d.ts.map +1 -0
- package/dist/components/custom-model-provider-slug.js +14 -0
- package/dist/components/custom-model-provider-slug.js.map +1 -0
- package/dist/components/default-model-field.d.ts +16 -0
- package/dist/components/default-model-field.d.ts.map +1 -0
- package/dist/components/default-model-field.js +60 -0
- package/dist/components/default-model-field.js.map +1 -0
- package/dist/components/form-errors.d.ts +11 -0
- package/dist/components/form-errors.d.ts.map +1 -0
- package/dist/components/form-errors.js +101 -0
- package/dist/components/form-errors.js.map +1 -0
- package/dist/components/harness-availability.d.ts +8 -0
- package/dist/components/harness-availability.d.ts.map +1 -0
- package/dist/components/harness-availability.js +16 -0
- package/dist/components/harness-availability.js.map +1 -0
- package/dist/components/harnesses-section.d.ts +4 -0
- package/dist/components/harnesses-section.d.ts.map +1 -0
- package/dist/components/harnesses-section.js +249 -0
- package/dist/components/harnesses-section.js.map +1 -0
- package/dist/components/harnesses-section.stories.d.ts +25 -0
- package/dist/components/harnesses-section.stories.d.ts.map +1 -0
- package/dist/components/harnesses-section.stories.js +138 -0
- package/dist/components/harnesses-section.stories.js.map +1 -0
- package/dist/components/model-provider-usage-modal.d.ts +11 -0
- package/dist/components/model-provider-usage-modal.d.ts.map +1 -0
- package/dist/components/model-provider-usage-modal.js +349 -0
- package/dist/components/model-provider-usage-modal.js.map +1 -0
- package/dist/components/model-provider-usage-modal.stories.d.ts +21 -0
- package/dist/components/model-provider-usage-modal.stories.d.ts.map +1 -0
- package/dist/components/model-provider-usage-modal.stories.js +115 -0
- package/dist/components/model-provider-usage-modal.stories.js.map +1 -0
- package/dist/components/model-provider-usage-target.d.ts +14 -0
- package/dist/components/model-provider-usage-target.d.ts.map +1 -0
- package/dist/components/model-provider-usage-target.js +20 -0
- package/dist/components/model-provider-usage-target.js.map +1 -0
- package/dist/components/model-providers-section.d.ts +4 -0
- package/dist/components/model-providers-section.d.ts.map +1 -0
- package/dist/components/model-providers-section.js +668 -0
- package/dist/components/model-providers-section.js.map +1 -0
- package/dist/components/model-providers-section.stories.d.ts +32 -0
- package/dist/components/model-providers-section.stories.d.ts.map +1 -0
- package/dist/components/model-providers-section.stories.js +442 -0
- package/dist/components/model-providers-section.stories.js.map +1 -0
- package/dist/components/provider-search.d.ts +4 -0
- package/dist/components/provider-search.d.ts.map +1 -0
- package/dist/components/provider-search.js +13 -0
- package/dist/components/provider-search.js.map +1 -0
- package/dist/components/supported-model-provider-catalog-entry.d.ts +8 -0
- package/dist/components/supported-model-provider-catalog-entry.d.ts.map +1 -0
- package/dist/components/supported-model-provider-catalog-entry.js +9 -0
- package/dist/components/supported-model-provider-catalog-entry.js.map +1 -0
- package/dist/components/test-and-save-form.d.ts +10 -0
- package/dist/components/test-and-save-form.d.ts.map +1 -0
- package/dist/components/test-and-save-form.js +138 -0
- package/dist/components/test-and-save-form.js.map +1 -0
- package/dist/feature.d.ts +9 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +13 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/model-providers.d.ts +334 -0
- package/dist/hooks/api/model-providers.d.ts.map +1 -0
- package/dist/hooks/api/model-providers.js +190 -0
- package/dist/hooks/api/model-providers.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/model-provider-onboarding-page.d.ts +6 -0
- package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -0
- package/dist/pages/model-provider-onboarding-page.js +295 -0
- package/dist/pages/model-provider-onboarding-page.js.map +1 -0
- package/dist/pages/model-provider-onboarding-page.stories.d.ts +27 -0
- package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -0
- package/dist/pages/model-provider-onboarding-page.stories.js +368 -0
- package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -0
- package/dist/routes/model-provider.d.ts +5 -0
- package/dist/routes/model-provider.d.ts.map +1 -0
- package/dist/routes/model-provider.js +28 -0
- package/dist/routes/model-provider.js.map +1 -0
- package/dist/state/model-provider-onboarding.d.ts +3 -0
- package/dist/state/model-provider-onboarding.d.ts.map +1 -0
- package/dist/state/model-provider-onboarding.js +28 -0
- package/dist/state/model-provider-onboarding.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +114 -0
- package/src/components/add-custom-provider-card.tsx +32 -0
- package/src/components/agent-workflow-example.test.ts +74 -0
- package/src/components/agent-workflow-example.ts +55 -0
- package/src/components/available-provider-card.tsx +39 -0
- package/src/components/available-providers-grid.test.tsx +55 -0
- package/src/components/available-providers-grid.tsx +106 -0
- package/src/components/change-default-model-form.tsx +104 -0
- package/src/components/custom-model-provider-api-options.test.ts +18 -0
- package/src/components/custom-model-provider-api-options.ts +12 -0
- package/src/components/custom-model-provider-form.stories.tsx +91 -0
- package/src/components/custom-model-provider-form.tsx +710 -0
- package/src/components/custom-model-provider-payload.test.ts +170 -0
- package/src/components/custom-model-provider-payload.ts +264 -0
- package/src/components/custom-model-provider-slug.test.ts +40 -0
- package/src/components/custom-model-provider-slug.ts +23 -0
- package/src/components/default-model-field.tsx +85 -0
- package/src/components/form-errors.test.ts +159 -0
- package/src/components/form-errors.ts +102 -0
- package/src/components/harness-availability.test.ts +45 -0
- package/src/components/harness-availability.ts +35 -0
- package/src/components/harnesses-section.stories.tsx +129 -0
- package/src/components/harnesses-section.test.tsx +188 -0
- package/src/components/harnesses-section.tsx +245 -0
- package/src/components/model-provider-usage-modal.stories.tsx +106 -0
- package/src/components/model-provider-usage-modal.test.tsx +210 -0
- package/src/components/model-provider-usage-modal.tsx +326 -0
- package/src/components/model-provider-usage-target.ts +36 -0
- package/src/components/model-providers-section.stories.tsx +345 -0
- package/src/components/model-providers-section.test.tsx +831 -0
- package/src/components/model-providers-section.tsx +645 -0
- package/src/components/provider-search.test.ts +45 -0
- package/src/components/provider-search.ts +18 -0
- package/src/components/supported-model-provider-catalog-entry.ts +19 -0
- package/src/components/test-and-save-form.tsx +166 -0
- package/src/feature.ts +12 -0
- package/src/hooks/api/model-providers.test.ts +167 -0
- package/src/hooks/api/model-providers.ts +281 -0
- package/src/index.ts +8 -0
- package/src/pages/model-provider-onboarding-page.stories.tsx +284 -0
- package/src/pages/model-provider-onboarding-page.test.tsx +368 -0
- package/src/pages/model-provider-onboarding-page.tsx +290 -0
- package/src/routes/model-provider.tsx +20 -0
- package/src/state/model-provider-onboarding.test.ts +55 -0
- package/src/state/model-provider-onboarding.ts +34 -0
- package/test/fixtures/model-providers.ts +111 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- 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
|
+
});
|