@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
package/package.json ADDED
@@ -0,0 +1,114 @@
1
+ {
2
+ "name": "@shipfox/client-agent",
3
+ "license": "MIT",
4
+ "version": "0.2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/client/agent"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "imports": {
14
+ "#*": {
15
+ "types": "./src/*",
16
+ "workspace-source": "./src/*",
17
+ "development": "./src/*",
18
+ "default": "./dist/*"
19
+ },
20
+ "#test/*": "./test/*"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "development": {
25
+ "types": "./src/index.ts",
26
+ "default": "./src/index.ts"
27
+ },
28
+ "default": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "./feature": {
34
+ "development": {
35
+ "types": "./src/feature.ts",
36
+ "default": "./src/feature.ts"
37
+ },
38
+ "default": {
39
+ "types": "./dist/feature.d.ts",
40
+ "default": "./dist/feature.js"
41
+ }
42
+ },
43
+ "./routes/*": {
44
+ "development": {
45
+ "types": "./src/routes/*.tsx",
46
+ "default": "./src/routes/*.tsx"
47
+ },
48
+ "default": {
49
+ "types": "./dist/routes/*.d.ts",
50
+ "default": "./dist/routes/*.js"
51
+ }
52
+ }
53
+ },
54
+ "dependencies": {
55
+ "@swc/helpers": "^0.5.17",
56
+ "@tanstack/react-form": "^1.32.0",
57
+ "@tanstack/react-query": "^5.101.0",
58
+ "@shipfox/api-agent-dto": "3.0.0",
59
+ "@shipfox/client-api": "0.2.0",
60
+ "@shipfox/client-shell": "0.2.0",
61
+ "@shipfox/client-ui": "0.2.0",
62
+ "@shipfox/react-ui": "0.3.1"
63
+ },
64
+ "peerDependencies": {
65
+ "@tanstack/react-query": "^5.101.0",
66
+ "@tanstack/react-router": "^1.170.16",
67
+ "react": "^19.0.0",
68
+ "react-dom": "^19.0.0"
69
+ },
70
+ "devDependencies": {
71
+ "@argos-ci/cli": "^5.0.0",
72
+ "@argos-ci/storybook": "^6.0.0",
73
+ "@storybook/addon-vitest": "^10.3.6",
74
+ "@storybook/react": "^10.0.0",
75
+ "@storybook/react-vite": "^10.0.0",
76
+ "@tailwindcss/vite": "^4.1.13",
77
+ "@testing-library/jest-dom": "^6.6.3",
78
+ "@testing-library/react": "^16.3.2",
79
+ "@testing-library/user-event": "^14.6.1",
80
+ "@types/react": "^19.1.11",
81
+ "@types/react-dom": "^19.1.7",
82
+ "@tanstack/react-query": "^5.101.0",
83
+ "@vitejs/plugin-react": "^6.0.0",
84
+ "@vitest/browser-playwright": "^4.1.5",
85
+ "happy-dom": "^20.8.3",
86
+ "jsdom": "^29.0.0",
87
+ "react": "^19.1.1",
88
+ "react-dom": "^19.1.1",
89
+ "storybook": "^10.0.0",
90
+ "storybook-addon-pseudo-states": "^10.0.0",
91
+ "tailwindcss": "^4.1.13",
92
+ "vite": "^8.0.3",
93
+ "vitest": "^4.1.5",
94
+ "yaml": "^2.8.3",
95
+ "@shipfox/biome": "1.8.1",
96
+ "@shipfox/client-test-setup": "0.0.2",
97
+ "@shipfox/ts-config": "1.3.8",
98
+ "@shipfox/swc": "1.2.5",
99
+ "@shipfox/typescript": "1.1.6",
100
+ "@shipfox/vitest": "1.2.2",
101
+ "@shipfox/workflow-document": "2.1.0"
102
+ },
103
+ "scripts": {
104
+ "build": "shipfox-swc",
105
+ "check": "shipfox-biome-check",
106
+ "check:fix": "shipfox-biome-check --write",
107
+ "storybook": "storybook dev -p 6010",
108
+ "storybook:build": "storybook build -o storybook-static",
109
+ "test": "shipfox-vitest-run",
110
+ "test:watch": "shipfox-vitest-watch",
111
+ "type": "shipfox-tsc-check",
112
+ "type:emit": "shipfox-tsc-emit"
113
+ }
114
+ }
@@ -0,0 +1,32 @@
1
+ import {Icon} from '@shipfox/react-ui/icon';
2
+ import {Text} from '@shipfox/react-ui/typography';
3
+ import {cn} from '@shipfox/react-ui/utils';
4
+
5
+ const SURFACE_CLASS =
6
+ 'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
7
+
8
+ export function AddCustomProviderCard({onConfigure}: {onConfigure: () => void}) {
9
+ return (
10
+ <li>
11
+ <button
12
+ type="button"
13
+ className={cn(
14
+ 'group block h-full w-full cursor-pointer p-16 text-left outline-none transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus',
15
+ SURFACE_CLASS,
16
+ )}
17
+ aria-label="Configure custom provider"
18
+ onClick={onConfigure}
19
+ >
20
+ <div className="flex min-w-0 items-center justify-between gap-12">
21
+ <Text size="md" bold className="min-w-0 truncate">
22
+ Custom
23
+ </Text>
24
+ <div className="flex shrink-0 items-center gap-4 text-foreground-neutral-muted transition-colors group-hover:text-foreground-highlight-interactive">
25
+ <Text size="sm">Configure</Text>
26
+ <Icon name="chevronRight" className="size-16" />
27
+ </div>
28
+ </div>
29
+ </button>
30
+ </li>
31
+ );
32
+ }
@@ -0,0 +1,74 @@
1
+ import {workflowDocumentSchema} from '@shipfox/workflow-document';
2
+ import {parse} from 'yaml';
3
+ import {buildAgentWorkflowExample} from './agent-workflow-example.js';
4
+
5
+ describe('buildAgentWorkflowExample', () => {
6
+ it.each([
7
+ {
8
+ harness: 'pi',
9
+ providerId: 'anthropic',
10
+ model: 'claude-opus-4-8',
11
+ expectedHarness: ' harness: pi',
12
+ expectedProvider: ' provider: anthropic',
13
+ expectedModel: ' model: claude-opus-4-8',
14
+ },
15
+ {
16
+ harness: 'claude',
17
+ providerId: 'anthropic',
18
+ model: 'anthropic/claude-opus-4.8',
19
+ expectedHarness: ' harness: claude',
20
+ expectedProvider: ' provider: anthropic',
21
+ expectedModel: ' model: anthropic/claude-opus-4.8',
22
+ },
23
+ {
24
+ harness: 'pi',
25
+ providerId: 'anthropic',
26
+ model: '@cf/moonshotai/kimi-k2.7-code',
27
+ expectedHarness: ' harness: pi',
28
+ expectedProvider: ' provider: anthropic',
29
+ expectedModel: " model: '@cf/moonshotai/kimi-k2.7-code'",
30
+ },
31
+ {
32
+ harness: 'pi',
33
+ providerId: 'provider#beta',
34
+ model: "model: beta's",
35
+ expectedHarness: ' harness: pi',
36
+ expectedProvider: " provider: 'provider#beta'",
37
+ expectedModel: " model: 'model: beta''s'",
38
+ },
39
+ {
40
+ harness: 'pi',
41
+ providerId: 'true',
42
+ model: '123',
43
+ expectedHarness: ' harness: pi',
44
+ expectedProvider: " provider: 'true'",
45
+ expectedModel: " model: '123'",
46
+ },
47
+ ])('builds valid workflow YAML for provider $providerId and model $model', ({
48
+ harness,
49
+ providerId,
50
+ model,
51
+ expectedHarness,
52
+ expectedProvider,
53
+ expectedModel,
54
+ }) => {
55
+ const example = buildAgentWorkflowExample({harness, providerId, model});
56
+
57
+ const lines = example.code.split('\n');
58
+ const parsed = parse(example.code);
59
+ const parsedStep = parsed as {
60
+ jobs: {agent: {steps: Array<{harness?: unknown; provider?: unknown; model?: unknown}>}};
61
+ };
62
+ const result = workflowDocumentSchema.safeParse(parsed);
63
+
64
+ expect(result.success).toBe(true);
65
+ expect(parsedStep.jobs.agent.steps[0]?.harness).toBe(harness);
66
+ expect(parsedStep.jobs.agent.steps[0]?.provider).toBe(providerId);
67
+ expect(parsedStep.jobs.agent.steps[0]?.model).toBe(model);
68
+ expect(lines).toContain(expectedHarness);
69
+ expect(lines).toContain(expectedProvider);
70
+ expect(lines).toContain(expectedModel);
71
+ expect(example.highlightedLineRange).toEqual({startLine: 11, endLine: 13});
72
+ expect(lines.slice(10, 13)).toEqual([expectedHarness, expectedProvider, expectedModel]);
73
+ });
74
+ });
@@ -0,0 +1,55 @@
1
+ import type {CodeBlockHighlightedLineRange} from '@shipfox/react-ui/code-block';
2
+
3
+ const YAML_SAFE_PLAIN_SCALAR_RE = /^[A-Za-z][A-Za-z0-9._/-]*$/;
4
+ const YAML_AMBIGUOUS_PLAIN_SCALAR_RE = /^(?:false|n|no|null|off|on|true|y|yes)$/i;
5
+
6
+ export interface AgentWorkflowExample {
7
+ code: string;
8
+ highlightedLineRange: CodeBlockHighlightedLineRange;
9
+ }
10
+
11
+ export function buildAgentWorkflowExample({
12
+ harness,
13
+ providerId,
14
+ model,
15
+ }: {
16
+ harness: string;
17
+ providerId: string;
18
+ model: string;
19
+ }): AgentWorkflowExample {
20
+ const lines = [
21
+ 'name: Agent',
22
+ 'triggers:',
23
+ ' on_demand:',
24
+ ' source: manual',
25
+ ' event: fire',
26
+ 'jobs:',
27
+ ' agent:',
28
+ ' runner: ubuntu-latest',
29
+ ' steps:',
30
+ ' - name: implement',
31
+ ` harness: ${formatYamlPlainOrSingleQuotedScalar(harness)}`,
32
+ ` provider: ${formatYamlPlainOrSingleQuotedScalar(providerId)}`,
33
+ ` model: ${formatYamlPlainOrSingleQuotedScalar(model)}`,
34
+ ' prompt: Describe the change you want the agent to make.',
35
+ ];
36
+ const harnessLineIndex = lines.findIndex((line) => line.trimStart().startsWith('harness:'));
37
+ const modelLineIndex = lines.findIndex((line) => line.trimStart().startsWith('model:'));
38
+
39
+ return {
40
+ code: lines.join('\n'),
41
+ highlightedLineRange: {
42
+ startLine: harnessLineIndex + 1,
43
+ endLine: modelLineIndex + 1,
44
+ },
45
+ };
46
+ }
47
+
48
+ function formatYamlPlainOrSingleQuotedScalar(value: string): string {
49
+ if (!requiresYamlSingleQuotes(value)) return value;
50
+ return `'${value.replaceAll("'", "''")}'`;
51
+ }
52
+
53
+ function requiresYamlSingleQuotes(value: string): boolean {
54
+ return !YAML_SAFE_PLAIN_SCALAR_RE.test(value) || YAML_AMBIGUOUS_PLAIN_SCALAR_RE.test(value);
55
+ }
@@ -0,0 +1,39 @@
1
+ import type {ModelProviderCatalogEntryDto} from '@shipfox/api-agent-dto';
2
+ import {Icon} from '@shipfox/react-ui/icon';
3
+ import {Text} from '@shipfox/react-ui/typography';
4
+ import {cn} from '@shipfox/react-ui/utils';
5
+
6
+ const SURFACE_CLASS =
7
+ 'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
8
+
9
+ export function AvailableProviderCard({
10
+ entry,
11
+ onConfigure,
12
+ }: {
13
+ entry: ModelProviderCatalogEntryDto;
14
+ onConfigure: () => void;
15
+ }) {
16
+ return (
17
+ <li>
18
+ <button
19
+ type="button"
20
+ className={cn(
21
+ 'group block w-full cursor-pointer p-16 text-left outline-none transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus',
22
+ SURFACE_CLASS,
23
+ )}
24
+ aria-label={`Configure ${entry.label}`}
25
+ onClick={onConfigure}
26
+ >
27
+ <div className="flex min-w-0 items-center justify-between gap-12">
28
+ <Text size="md" bold className="min-w-0 truncate">
29
+ {entry.label}
30
+ </Text>
31
+ <div className="flex shrink-0 items-center gap-4 text-foreground-neutral-muted transition-colors group-hover:text-foreground-highlight-interactive">
32
+ <Text size="sm">Configure</Text>
33
+ <Icon name="chevronRight" className="size-16" />
34
+ </div>
35
+ </div>
36
+ </button>
37
+ </li>
38
+ );
39
+ }
@@ -0,0 +1,55 @@
1
+ import {render, screen, waitFor} from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import {testModelProviderEntries} from '#test/fixtures/model-providers.js';
4
+ import {AvailableProvidersGrid} from './available-providers-grid.js';
5
+
6
+ describe('AvailableProvidersGrid', () => {
7
+ test('shows the search field when more than eight providers are available', () => {
8
+ render(<AvailableProvidersGrid entries={testModelProviderEntries(9)} onSelect={vi.fn()} />);
9
+
10
+ expect(screen.getByRole('searchbox', {name: 'Search providers'})).toBeVisible();
11
+ });
12
+
13
+ test('hides the search field when eight or fewer providers are available without a query', () => {
14
+ render(<AvailableProvidersGrid entries={testModelProviderEntries(8)} onSelect={vi.fn()} />);
15
+
16
+ expect(screen.queryByRole('searchbox', {name: 'Search providers'})).not.toBeInTheDocument();
17
+ expect(screen.getByRole('list', {name: 'Available providers'})).toBeVisible();
18
+ expect(screen.getByRole('status')).toHaveTextContent('');
19
+ });
20
+
21
+ test('keeps the search field mounted when a query is active and the entries shrink below the threshold', async () => {
22
+ const user = userEvent.setup();
23
+ const {rerender} = render(
24
+ <AvailableProvidersGrid entries={testModelProviderEntries(9)} onSelect={vi.fn()} />,
25
+ );
26
+ await user.type(screen.getByRole('searchbox', {name: 'Search providers'}), 'provider 1');
27
+
28
+ rerender(<AvailableProvidersGrid entries={testModelProviderEntries(8)} onSelect={vi.fn()} />);
29
+
30
+ expect(screen.getByRole('searchbox', {name: 'Search providers'})).toHaveValue('provider 1');
31
+ });
32
+
33
+ test('clear search resets the query and returns focus to the input', async () => {
34
+ const user = userEvent.setup();
35
+ render(<AvailableProvidersGrid entries={testModelProviderEntries(9)} onSelect={vi.fn()} />);
36
+ const search = screen.getByRole('searchbox', {name: 'Search providers'});
37
+
38
+ await user.type(search, 'missing');
39
+ await user.click(screen.getByRole('button', {name: 'Clear search'}));
40
+
41
+ expect(search).toHaveValue('');
42
+ await waitFor(() => expect(search).toHaveFocus());
43
+ expect(screen.getByRole('button', {name: 'Configure Provider 0'})).toBeVisible();
44
+ });
45
+
46
+ test('announces the filtered result count while a query is active', async () => {
47
+ const user = userEvent.setup();
48
+ render(<AvailableProvidersGrid entries={testModelProviderEntries(9)} onSelect={vi.fn()} />);
49
+
50
+ await user.type(screen.getByRole('searchbox', {name: 'Search providers'}), 'provider 1');
51
+
52
+ expect(screen.getByRole('status')).toHaveTextContent('1 provider matches "provider 1"');
53
+ expect(screen.getByRole('list', {name: 'Available providers matching search'})).toBeVisible();
54
+ });
55
+ });
@@ -0,0 +1,106 @@
1
+ import type {ModelProviderCatalogEntryDto} from '@shipfox/api-agent-dto';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
4
+ import {Icon} from '@shipfox/react-ui/icon';
5
+ import {Input} from '@shipfox/react-ui/input';
6
+ import type {ReactNode} from 'react';
7
+ import {useMemo, useRef, useState} from 'react';
8
+ import {AvailableProviderCard} from './available-provider-card.js';
9
+ import {providerMatchesSearch} from './provider-search.js';
10
+
11
+ export const PROVIDER_GRID_CLASS = 'grid grid-cols-2 gap-12 max-[760px]:grid-cols-1';
12
+
13
+ const SEARCH_VISIBILITY_THRESHOLD = 8;
14
+ const MAX_ECHOED_QUERY_LENGTH = 40;
15
+
16
+ export function AvailableProvidersGrid<TEntry extends ModelProviderCatalogEntryDto>({
17
+ entries,
18
+ onSelect,
19
+ trailingCard,
20
+ trailingCardMatchesSearch = () => true,
21
+ }: {
22
+ entries: TEntry[];
23
+ onSelect: (entry: TEntry) => void;
24
+ trailingCard?: ReactNode | undefined;
25
+ trailingCardMatchesSearch?: ((query: string) => boolean) | undefined;
26
+ }) {
27
+ const [search, setSearch] = useState('');
28
+ const inputRef = useRef<HTMLInputElement | null>(null);
29
+ const trimmedSearch = search.trim();
30
+ const filteredEntries = useMemo(
31
+ () => entries.filter((entry) => providerMatchesSearch(entry, search)),
32
+ [entries, search],
33
+ );
34
+ const trailingCardVisible = trailingCard !== undefined && trailingCardMatchesSearch(search);
35
+ const visibleCount = filteredEntries.length + (trailingCardVisible ? 1 : 0);
36
+ const showSearch = entries.length > SEARCH_VISIBILITY_THRESHOLD || trimmedSearch !== '';
37
+ const providerListLabel =
38
+ trimmedSearch !== '' ? 'Available providers matching search' : 'Available providers';
39
+ const resultCountText =
40
+ trimmedSearch === '' ? '' : providerResultCountText(filteredEntries.length, trimmedSearch);
41
+
42
+ function clearSearch() {
43
+ setSearch('');
44
+ inputRef.current?.focus();
45
+ }
46
+
47
+ return (
48
+ <div className="flex flex-col gap-12">
49
+ {showSearch ? (
50
+ <Input
51
+ ref={inputRef}
52
+ type="search"
53
+ aria-label="Search providers"
54
+ placeholder="Search providers..."
55
+ value={search}
56
+ iconLeft={<Icon name="searchLine" className="size-16 text-foreground-neutral-muted" />}
57
+ onChange={(event) => setSearch(event.target.value)}
58
+ />
59
+ ) : null}
60
+
61
+ {visibleCount > 0 ? (
62
+ <ul className={PROVIDER_GRID_CLASS} aria-label={providerListLabel}>
63
+ {filteredEntries.map((entry) => (
64
+ <AvailableProviderCard
65
+ key={entry.id}
66
+ entry={entry}
67
+ onConfigure={() => onSelect(entry)}
68
+ />
69
+ ))}
70
+ {trailingCardVisible ? trailingCard : null}
71
+ </ul>
72
+ ) : (
73
+ <NoProviderSearchResults search={trimmedSearch} onClear={clearSearch} />
74
+ )}
75
+
76
+ <p role="status" aria-live="polite" className="sr-only">
77
+ {resultCountText}
78
+ </p>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ function NoProviderSearchResults({search, onClear}: {search: string; onClear: () => void}) {
84
+ return (
85
+ <EmptyState
86
+ icon="searchLine"
87
+ title={`No providers match "${truncateQuery(search)}"`}
88
+ description="Try a different search, or clear it to see all providers."
89
+ action={
90
+ <Button size="sm" variant="secondary" onClick={onClear}>
91
+ Clear search
92
+ </Button>
93
+ }
94
+ />
95
+ );
96
+ }
97
+
98
+ function truncateQuery(query: string): string {
99
+ if (query.length <= MAX_ECHOED_QUERY_LENGTH) return query;
100
+ return `${query.slice(0, MAX_ECHOED_QUERY_LENGTH - 3)}...`;
101
+ }
102
+
103
+ function providerResultCountText(count: number, query: string): string {
104
+ if (count === 1) return `1 provider matches "${query}"`;
105
+ return `${count} providers match "${query}"`;
106
+ }
@@ -0,0 +1,104 @@
1
+ import type {ModelProviderConfigDto} from '@shipfox/api-agent-dto';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Callout} from '@shipfox/react-ui/callout';
4
+ import {fieldError} from '@shipfox/react-ui/form-field';
5
+ import {ModalBody, ModalFooter} from '@shipfox/react-ui/modal';
6
+ import {Text} from '@shipfox/react-ui/typography';
7
+ import {useForm} from '@tanstack/react-form';
8
+ import {useState} from 'react';
9
+ import {useUpdateModelProviderDefaultModelMutation} from '#hooks/api/model-providers.js';
10
+ import {
11
+ DefaultModelField,
12
+ defaultModelFormValue,
13
+ selectedModelForModelPayload,
14
+ } from './default-model-field.js';
15
+ import {modelProviderConfigErrorToFormError} from './form-errors.js';
16
+ import type {SupportedModelProviderCatalogEntry} from './supported-model-provider-catalog-entry.js';
17
+
18
+ const CHANGE_DEFAULT_MODEL_FORM_ID = 'model-provider-change-default-model-form';
19
+
20
+ export function ChangeDefaultModelForm({
21
+ workspaceId,
22
+ entry,
23
+ config,
24
+ onSaved,
25
+ }: {
26
+ workspaceId: string;
27
+ entry: SupportedModelProviderCatalogEntry;
28
+ config: ModelProviderConfigDto;
29
+ onSaved: () => void;
30
+ }) {
31
+ const updateDefaultModel = useUpdateModelProviderDefaultModelMutation();
32
+ const [formError, setFormError] = useState<string | undefined>();
33
+ const form = useForm({
34
+ defaultValues: {default_model: defaultModelFormValue(config.default_model)},
35
+ onSubmit: async ({value}) => {
36
+ setFormError(undefined);
37
+ try {
38
+ await updateDefaultModel.mutateAsync({
39
+ workspaceId,
40
+ providerId: entry.id,
41
+ body: {default_model: selectedModelForModelPayload(value.default_model)},
42
+ });
43
+ onSaved();
44
+ } catch (error) {
45
+ const mapped = modelProviderConfigErrorToFormError(error);
46
+ setFormError(mapped.message);
47
+ }
48
+ },
49
+ });
50
+
51
+ return (
52
+ <>
53
+ <ModalBody className="gap-16">
54
+ <form
55
+ id={CHANGE_DEFAULT_MODEL_FORM_ID}
56
+ className="flex w-full flex-col gap-14"
57
+ noValidate
58
+ onSubmit={(event) => {
59
+ event.preventDefault();
60
+ event.stopPropagation();
61
+ void form.handleSubmit();
62
+ }}
63
+ >
64
+ <form.Field
65
+ name="default_model"
66
+ validators={{
67
+ onBlur: ({value}) => (value.trim() ? undefined : 'Default model is required.'),
68
+ onSubmit: ({value}) => (value.trim() ? undefined : 'Default model is required.'),
69
+ }}
70
+ >
71
+ {(field) => (
72
+ <DefaultModelField
73
+ entry={entry}
74
+ value={field.state.value}
75
+ error={fieldError(field)}
76
+ onChange={(event) => field.handleChange(event.target.value)}
77
+ onBlur={field.handleBlur}
78
+ />
79
+ )}
80
+ </form.Field>
81
+ </form>
82
+ {formError ? (
83
+ <Callout role="alert" type="error">
84
+ <div className="flex flex-col gap-8">
85
+ <Text size="sm" bold>
86
+ Could not save default model
87
+ </Text>
88
+ <Text size="sm">{formError}</Text>
89
+ </div>
90
+ </Callout>
91
+ ) : null}
92
+ </ModalBody>
93
+ <ModalFooter>
94
+ <Button
95
+ type="submit"
96
+ form={CHANGE_DEFAULT_MODEL_FORM_ID}
97
+ isLoading={updateDefaultModel.isPending}
98
+ >
99
+ Save model
100
+ </Button>
101
+ </ModalFooter>
102
+ </>
103
+ );
104
+ }
@@ -0,0 +1,18 @@
1
+ import {
2
+ MODEL_PROVIDER_API_OPTIONS,
3
+ modelProviderApiLabel,
4
+ } from './custom-model-provider-api-options.js';
5
+
6
+ describe('modelProviderApiLabel', () => {
7
+ test('returns configured protocol labels', () => {
8
+ const result = modelProviderApiLabel('anthropic-messages');
9
+
10
+ expect(result).toBe('Anthropic Messages');
11
+ });
12
+
13
+ test('covers every protocol option with a label', () => {
14
+ const labels = MODEL_PROVIDER_API_OPTIONS.map((option) => modelProviderApiLabel(option.value));
15
+
16
+ expect(labels).toEqual(MODEL_PROVIDER_API_OPTIONS.map((option) => option.label));
17
+ });
18
+ });
@@ -0,0 +1,12 @@
1
+ import type {ModelProviderApi} from '@shipfox/api-agent-dto';
2
+
3
+ export const MODEL_PROVIDER_API_OPTIONS: Array<{value: ModelProviderApi; label: string}> = [
4
+ {value: 'openai-completions', label: 'OpenAI Chat Completions'},
5
+ {value: 'openai-responses', label: 'OpenAI Responses'},
6
+ {value: 'anthropic-messages', label: 'Anthropic Messages'},
7
+ {value: 'google-generative-ai', label: 'Google Generative AI'},
8
+ ];
9
+
10
+ export function modelProviderApiLabel(api: ModelProviderApi): string {
11
+ return MODEL_PROVIDER_API_OPTIONS.find((option) => option.value === api)?.label ?? api;
12
+ }