@shipfox/client-projects 4.0.0 → 6.0.1

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 (109) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +78 -0
  3. package/dist/chrome.d.ts.map +1 -1
  4. package/dist/chrome.js +7 -10
  5. package/dist/chrome.js.map +1 -1
  6. package/dist/components/model-provider-reminder-banner.d.ts.map +1 -1
  7. package/dist/components/model-provider-reminder-banner.js +1 -1
  8. package/dist/components/model-provider-reminder-banner.js.map +1 -1
  9. package/dist/components/source-strip.d.ts +2 -2
  10. package/dist/components/source-strip.d.ts.map +1 -1
  11. package/dist/components/source-strip.js +2 -2
  12. package/dist/components/source-strip.js.map +1 -1
  13. package/dist/core/definition.d.ts +32 -0
  14. package/dist/core/definition.d.ts.map +1 -0
  15. package/dist/core/definition.js +3 -0
  16. package/dist/core/definition.js.map +1 -0
  17. package/dist/core/project.d.ts +26 -0
  18. package/dist/core/project.d.ts.map +1 -0
  19. package/dist/core/project.js +12 -0
  20. package/dist/core/project.js.map +1 -0
  21. package/dist/feature.d.ts +8 -10
  22. package/dist/feature.d.ts.map +1 -1
  23. package/dist/feature.js +11 -22
  24. package/dist/feature.js.map +1 -1
  25. package/dist/hooks/api/definitions.d.ts +12 -88
  26. package/dist/hooks/api/definitions.d.ts.map +1 -1
  27. package/dist/hooks/api/definitions.js +18 -10
  28. package/dist/hooks/api/definitions.js.map +1 -1
  29. package/dist/hooks/api/mappers.d.ts +17 -0
  30. package/dist/hooks/api/mappers.d.ts.map +1 -0
  31. package/dist/hooks/api/mappers.js +56 -0
  32. package/dist/hooks/api/mappers.js.map +1 -0
  33. package/dist/hooks/api/projects.d.ts +19 -81
  34. package/dist/hooks/api/projects.d.ts.map +1 -1
  35. package/dist/hooks/api/projects.js +72 -18
  36. package/dist/hooks/api/projects.js.map +1 -1
  37. package/dist/index.d.ts +3 -3
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2 -2
  40. package/dist/index.js.map +1 -1
  41. package/dist/pages/create-project-page.d.ts.map +1 -1
  42. package/dist/pages/create-project-page.js +12 -37
  43. package/dist/pages/create-project-page.js.map +1 -1
  44. package/dist/pages/create-project-page.stories.d.ts.map +1 -1
  45. package/dist/pages/create-project-page.stories.js +4 -5
  46. package/dist/pages/create-project-page.stories.js.map +1 -1
  47. package/dist/pages/home-router.d.ts +4 -1
  48. package/dist/pages/home-router.d.ts.map +1 -1
  49. package/dist/pages/home-router.js +4 -2
  50. package/dist/pages/home-router.js.map +1 -1
  51. package/dist/pages/projects-hub-page.d.ts +3 -1
  52. package/dist/pages/projects-hub-page.d.ts.map +1 -1
  53. package/dist/pages/projects-hub-page.js +26 -30
  54. package/dist/pages/projects-hub-page.js.map +1 -1
  55. package/dist/routes/home.d.ts +3 -2
  56. package/dist/routes/home.d.ts.map +1 -1
  57. package/dist/routes/home.js +7 -2
  58. package/dist/routes/home.js.map +1 -1
  59. package/dist/routes/inputs.d.ts +6 -0
  60. package/dist/routes/inputs.d.ts.map +1 -0
  61. package/dist/routes/inputs.js +14 -0
  62. package/dist/routes/inputs.js.map +1 -0
  63. package/dist/routes/search.d.ts +5 -0
  64. package/dist/routes/search.d.ts.map +1 -0
  65. package/dist/routes/search.js +8 -0
  66. package/dist/routes/search.js.map +1 -0
  67. package/dist/tsconfig.test.tsbuildinfo +1 -1
  68. package/package.json +14 -17
  69. package/src/chrome.tsx +11 -6
  70. package/src/components/model-provider-reminder-banner.tsx +1 -3
  71. package/src/components/project-switcher.test.tsx +2 -1
  72. package/src/components/source-strip.tsx +5 -5
  73. package/src/core/definition.ts +31 -0
  74. package/src/core/project.ts +47 -0
  75. package/src/feature.ts +13 -23
  76. package/src/hooks/api/definitions.ts +54 -19
  77. package/src/hooks/api/mappers.ts +67 -0
  78. package/src/hooks/api/projects.test.ts +11 -4
  79. package/src/hooks/api/projects.ts +125 -28
  80. package/src/index.ts +10 -6
  81. package/src/pages/create-project-page.stories.tsx +7 -5
  82. package/src/pages/create-project-page.test.tsx +3 -1
  83. package/src/pages/create-project-page.tsx +16 -51
  84. package/src/pages/home-router.tsx +3 -2
  85. package/src/pages/projects-hub-page.test.tsx +13 -8
  86. package/src/pages/projects-hub-page.tsx +24 -34
  87. package/src/routes/home.tsx +6 -2
  88. package/src/routes/inputs.test.ts +14 -0
  89. package/src/routes/inputs.ts +15 -0
  90. package/src/routes/search.ts +8 -0
  91. package/test/pages.tsx +2 -17
  92. package/tsconfig.build.tsbuildinfo +1 -1
  93. package/dist/components/workspace-setup-guard.d.ts +0 -9
  94. package/dist/components/workspace-setup-guard.d.ts.map +0 -1
  95. package/dist/components/workspace-setup-guard.js +0 -160
  96. package/dist/components/workspace-setup-guard.js.map +0 -1
  97. package/dist/pages/project-workflows-page.d.ts +0 -4
  98. package/dist/pages/project-workflows-page.d.ts.map +0 -1
  99. package/dist/pages/project-workflows-page.js +0 -481
  100. package/dist/pages/project-workflows-page.js.map +0 -1
  101. package/dist/routes/workflows.d.ts +0 -5
  102. package/dist/routes/workflows.d.ts.map +0 -1
  103. package/dist/routes/workflows.js +0 -16
  104. package/dist/routes/workflows.js.map +0 -1
  105. package/src/components/workspace-setup-guard.test.tsx +0 -534
  106. package/src/components/workspace-setup-guard.tsx +0 -185
  107. package/src/pages/project-workflows-page.test.tsx +0 -258
  108. package/src/pages/project-workflows-page.tsx +0 -445
  109. package/src/routes/workflows.tsx +0 -10
@@ -1,19 +1,56 @@
1
- import type {
2
- CreateProjectBodyDto,
3
- ListProjectsResponseDto,
4
- ProjectResponseDto,
1
+ import {
2
+ createProjectBodySchema,
3
+ listProjectsResponseSchema,
4
+ projectResponseSchema,
5
5
  } from '@shipfox/api-projects-dto';
6
- import {apiRequest} from '@shipfox/client-api';
7
- import {keepPreviousData, useInfiniteQuery, useMutation, useQuery} from '@tanstack/react-query';
6
+ import {checkedApiRequest} from '@shipfox/client-api';
7
+ import {
8
+ type InfiniteData,
9
+ keepPreviousData,
10
+ queryOptions,
11
+ type UseInfiniteQueryOptions,
12
+ type UseQueryOptions,
13
+ useInfiniteQuery,
14
+ useMutation,
15
+ useQuery,
16
+ useQueryClient,
17
+ } from '@tanstack/react-query';
18
+ import type {CreateProjectCommand, Project, ProjectList} from '#core/project.js';
19
+ import {toProject, toProjectList} from './mappers.js';
8
20
 
9
21
  export const projectsQueryKeys = {
10
22
  all: ['projects'] as const,
11
- list: (workspaceId: string, search?: string) =>
12
- [...projectsQueryKeys.all, 'list', workspaceId, search ?? ''] as const,
23
+ list: (workspaceId: string, search = '') =>
24
+ [...projectsQueryKeys.all, 'list', workspaceId, search] as const,
13
25
  exists: (workspaceId: string) => [...projectsQueryKeys.all, 'exists', workspaceId] as const,
14
26
  detail: (projectId: string) => [...projectsQueryKeys.all, 'detail', projectId] as const,
15
27
  };
16
28
 
29
+ type ProjectListQueryKey =
30
+ | ReturnType<typeof projectsQueryKeys.list>
31
+ | readonly ['projects', 'list'];
32
+ type ProjectExistenceQueryKey =
33
+ | ReturnType<typeof projectsQueryKeys.exists>
34
+ | readonly ['projects', 'exists'];
35
+ type ProjectDetailQueryKey =
36
+ | ReturnType<typeof projectsQueryKeys.detail>
37
+ | readonly ['projects', 'detail'];
38
+
39
+ type ProjectListInfiniteQueryOptions = UseInfiniteQueryOptions<
40
+ ProjectList,
41
+ Error,
42
+ InfiniteData<ProjectList, string | undefined>,
43
+ ProjectListQueryKey,
44
+ string | undefined
45
+ >;
46
+ type ProjectExistenceQueryOptions = UseQueryOptions<
47
+ ProjectList,
48
+ Error,
49
+ ProjectList,
50
+ ProjectExistenceQueryKey
51
+ >;
52
+ type ProjectDetailQueryOptions = UseQueryOptions<Project, Error, Project, ProjectDetailQueryKey>;
53
+
17
54
  export async function listProjects({
18
55
  workspaceId,
19
56
  limit = 50,
@@ -25,51 +62,111 @@ export async function listProjects({
25
62
  limit?: number;
26
63
  cursor?: string | undefined;
27
64
  search?: string | undefined;
28
- signal?: AbortSignal;
29
- }) {
65
+ signal?: AbortSignal | undefined;
66
+ }): Promise<ProjectList> {
30
67
  const params = new URLSearchParams({workspace_id: workspaceId, limit: String(limit)});
31
68
  if (cursor) params.set('cursor', cursor);
32
69
  if (search) params.set('search', search);
33
-
34
- return await apiRequest<ListProjectsResponseDto>(`/projects?${params.toString()}`, {signal});
70
+ return toProjectList(
71
+ await checkedApiRequest(listProjectsResponseSchema, `/projects?${params.toString()}`, {signal}),
72
+ );
35
73
  }
36
74
 
37
- export async function getProject(projectId: string) {
38
- return await apiRequest<ProjectResponseDto>(`/projects/${projectId}`);
75
+ export async function getProject(projectId: string): Promise<Project> {
76
+ return toProject(await checkedApiRequest(projectResponseSchema, `/projects/${projectId}`));
39
77
  }
40
78
 
41
- export async function createProject(body: CreateProjectBodyDto) {
42
- return await apiRequest<ProjectResponseDto>('/projects', {method: 'POST', body});
79
+ export async function createProject(command: CreateProjectCommand): Promise<Project> {
80
+ const body = createProjectBodySchema.parse({
81
+ workspace_id: command.workspaceId,
82
+ name: command.name,
83
+ source: {
84
+ connection_id: command.source.connectionId,
85
+ external_repository_id: command.source.externalRepositoryId,
86
+ },
87
+ });
88
+ return toProject(
89
+ await checkedApiRequest(projectResponseSchema, '/projects', {method: 'POST', body}),
90
+ );
43
91
  }
44
92
 
45
- export function useProjectsInfiniteQuery(
93
+ export function projectsInfiniteQueryOptions(
46
94
  workspaceId: string | undefined,
47
95
  search?: string,
48
96
  limit = 50,
49
- ) {
50
- return useInfiniteQuery({
97
+ ): ProjectListInfiniteQueryOptions {
98
+ const normalizedSearch = search?.trim() ?? '';
99
+ return {
51
100
  queryKey: workspaceId
52
- ? projectsQueryKeys.list(workspaceId, search)
53
- : [...projectsQueryKeys.all, 'list'],
101
+ ? projectsQueryKeys.list(workspaceId, normalizedSearch)
102
+ : ([...projectsQueryKeys.all, 'list'] as const),
54
103
  enabled: Boolean(workspaceId),
55
104
  initialPageParam: undefined as string | undefined,
56
- queryFn: ({pageParam, signal}) =>
57
- listProjects({workspaceId: workspaceId ?? '', limit, cursor: pageParam, search, signal}),
58
- getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
105
+ queryFn: ({pageParam, signal}: {pageParam: string | undefined; signal: AbortSignal}) =>
106
+ listProjects({
107
+ workspaceId: workspaceId ?? '',
108
+ limit,
109
+ cursor: pageParam,
110
+ search: normalizedSearch || undefined,
111
+ signal,
112
+ }),
113
+ getNextPageParam: (lastPage: ProjectList) => lastPage.nextCursor ?? undefined,
59
114
  placeholderData: keepPreviousData,
115
+ };
116
+ }
117
+
118
+ export function projectExistenceQueryOptions(
119
+ workspaceId: string | undefined,
120
+ ): ProjectExistenceQueryOptions {
121
+ return queryOptions({
122
+ queryKey: workspaceId
123
+ ? projectsQueryKeys.exists(workspaceId)
124
+ : ([...projectsQueryKeys.all, 'exists'] as const),
125
+ enabled: Boolean(workspaceId),
126
+ queryFn: ({signal}) => listProjects({workspaceId: workspaceId ?? '', limit: 1, signal}),
127
+ staleTime: 30_000,
60
128
  });
61
129
  }
62
130
 
63
- export function useProjectQuery(projectId: string | undefined) {
64
- return useQuery({
131
+ export function projectQueryOptions(projectId: string | undefined): ProjectDetailQueryOptions {
132
+ return queryOptions({
65
133
  queryKey: projectId
66
134
  ? projectsQueryKeys.detail(projectId)
67
- : [...projectsQueryKeys.all, 'detail'],
135
+ : ([...projectsQueryKeys.all, 'detail'] as const),
68
136
  enabled: Boolean(projectId),
69
137
  queryFn: () => getProject(projectId ?? ''),
70
138
  });
71
139
  }
72
140
 
141
+ export function useProjectsInfiniteQuery(
142
+ workspaceId: string | undefined,
143
+ search?: string,
144
+ limit = 50,
145
+ ) {
146
+ return useInfiniteQuery(projectsInfiniteQueryOptions(workspaceId, search, limit));
147
+ }
148
+ export function useProjectQuery(projectId: string | undefined) {
149
+ return useQuery(projectQueryOptions(projectId));
150
+ }
151
+
73
152
  export function useCreateProjectMutation() {
74
- return useMutation({mutationFn: createProject});
153
+ const queryClient = useQueryClient();
154
+ return useMutation({
155
+ mutationFn: createProject,
156
+ onSuccess: async (project) => {
157
+ queryClient.setQueryData(projectsQueryKeys.detail(project.id), project);
158
+ queryClient.setQueryData<ProjectList>(projectsQueryKeys.exists(project.workspaceId), {
159
+ projects: [project],
160
+ nextCursor: null,
161
+ });
162
+ await queryClient.invalidateQueries({queryKey: projectsQueryKeys.list(project.workspaceId)});
163
+ },
164
+ onError: async (_error, command) => {
165
+ await queryClient.invalidateQueries({
166
+ queryKey: projectsQueryKeys.exists(command.workspaceId),
167
+ refetchType: 'active',
168
+ });
169
+ await queryClient.invalidateQueries({queryKey: projectsQueryKeys.list(command.workspaceId)});
170
+ },
171
+ });
75
172
  }
package/src/index.ts CHANGED
@@ -1,15 +1,19 @@
1
- export type {WorkspaceSetupState} from '@shipfox/client-shell/runtime';
1
+ export type {Definition, DefinitionList, DefinitionSyncSummary} from '#core/definition.js';
2
+ export {
3
+ type CreateProjectCommand,
4
+ type Project,
5
+ type ProjectList,
6
+ type ProjectSource,
7
+ projectNameFromRepository,
8
+ selectProjectSource,
9
+ } from '#core/project.js';
2
10
  export {ProjectBreadcrumb, ProjectLayoutGuard} from './chrome.js';
3
11
  export {ProjectCrumb, type ProjectCrumbProps} from './components/project-crumb.js';
4
12
  export {ProjectSwitcher, type ProjectSwitcherProps} from './components/project-switcher.js';
5
- export {
6
- loadWorkspaceSetupRoute,
7
- type WorkspaceSetupRouteOptions,
8
- } from './components/workspace-setup-guard.js';
13
+ export {SourceStrip} from './components/source-strip.js';
9
14
  export * from './hooks/api/definitions.js';
10
15
  export * from './hooks/api/projects.js';
11
16
  export * from './pages/create-project-page.js';
12
17
  export * from './pages/home-router.js';
13
- export * from './pages/project-workflows-page.js';
14
18
  export * from './pages/projects-hub-page.js';
15
19
  export * from './project-error.js';
@@ -39,10 +39,7 @@ const authState: AuthState = {
39
39
  id: '22222222-2222-4222-8222-222222222222',
40
40
  email: 'platform@example.com',
41
41
  name: 'Platform Engineer',
42
- email_verified_at: '2026-01-01T00:00:00.000Z',
43
- status: 'active',
44
- created_at: '2026-01-01T00:00:00.000Z',
45
- updated_at: '2026-01-01T00:00:00.000Z',
42
+ emailVerifiedAt: '2026-01-01T00:00:00.000Z',
46
43
  },
47
44
  workspaces: [{id: WORKSPACE_ID, name: 'Acme', membershipId: 'membership-1'}],
48
45
  };
@@ -153,7 +150,11 @@ function fetchForScenario(scenario: Scenario): typeof fetch {
153
150
  const method = input instanceof Request ? input.method : (init?.method ?? 'GET');
154
151
  if (url.pathname === `/workspaces/${WORKSPACE_ID}/agent/model-providers`) {
155
152
  return Promise.resolve(
156
- jsonResponse({configs: [modelProviderConfig()], default_provider_id: 'openai'}),
153
+ jsonResponse({
154
+ configs: [modelProviderConfig()],
155
+ default_provider_id: 'openai',
156
+ default_harness_id: null,
157
+ }),
157
158
  );
158
159
  }
159
160
  if (url.pathname === '/integration-connections') {
@@ -315,6 +316,7 @@ function repository(overrides: Partial<RepositoryDto>): RepositoryDto {
315
316
 
316
317
  function modelProviderConfig() {
317
318
  return {
319
+ kind: 'builtin',
318
320
  workspace_id: WORKSPACE_ID,
319
321
  provider_id: 'openai',
320
322
  default_model: 'gpt-5.5-pro',
@@ -163,7 +163,9 @@ describe('CreateProjectPage', () => {
163
163
  const fetchImpl = vi.fn((input: RequestInfo | URL) => {
164
164
  const request = input as Request;
165
165
  if (request.url.endsWith('/agent/model-providers')) {
166
- return Promise.resolve(jsonResponse({configs: [], default_provider_id: null}));
166
+ return Promise.resolve(
167
+ jsonResponse({configs: [], default_provider_id: null, default_harness_id: null}),
168
+ );
167
169
  }
168
170
  if (request.url.includes('/integration-connections?')) {
169
171
  return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
@@ -1,12 +1,8 @@
1
- import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
2
- import {
3
- createProjectBodySchema,
4
- type ListProjectsResponseDto,
5
- type ProjectResponseDto,
6
- } from '@shipfox/api-projects-dto';
1
+ import {createProjectBodySchema} from '@shipfox/api-projects-dto';
7
2
  import {useMaybeActiveWorkspace} from '@shipfox/client-auth';
8
3
  import {
9
4
  ConnectionPicker,
5
+ type IntegrationConnection,
10
6
  IntegrationIcon,
11
7
  RepositoryPicker,
12
8
  useRepositoriesInfiniteQuery,
@@ -20,24 +16,21 @@ import {FullPageLoader} from '@shipfox/react-ui/loader';
20
16
  import {toast} from '@shipfox/react-ui/toast';
21
17
  import {Header, Text} from '@shipfox/react-ui/typography';
22
18
  import {useForm} from '@tanstack/react-form';
23
- import {type QueryClient, useQueryClient} from '@tanstack/react-query';
24
19
  import {Link, Navigate, useNavigate} from '@tanstack/react-router';
25
20
  import {useEffect, useRef, useState} from 'react';
26
21
  import {ModelProviderReminderBanner} from '#components/model-provider-reminder-banner.js';
27
- import {projectsQueryKeys, useCreateProjectMutation} from '#hooks/api/projects.js';
22
+ import {type CreateProjectCommand, projectNameFromRepository} from '#core/project.js';
23
+ import {useCreateProjectMutation} from '#hooks/api/projects.js';
28
24
  import {projectErrorCopy} from '#project-error.js';
29
25
 
30
- const REPOSITORY_NAME_SPLIT_RE = /[/-]/;
31
-
32
26
  export function CreateProjectPage() {
33
27
  const workspace = useMaybeActiveWorkspace();
34
- const queryClient = useQueryClient();
35
28
  const navigate = useNavigate();
36
29
  const createProject = useCreateProjectMutation();
37
30
  const errorRef = useRef<HTMLDivElement>(null);
38
31
 
39
32
  const connectionsQuery = useSourceConnectionsQuery(workspace?.id);
40
- const connections = connectionsQuery.data?.connections ?? [];
33
+ const connections = connectionsQuery.data ?? [];
41
34
 
42
35
  const [selectedConnectionId, setSelectedConnectionId] = useState<string | undefined>();
43
36
  const singleConnectionId = connections.length === 1 ? connections[0]?.id : undefined;
@@ -48,7 +41,7 @@ export function CreateProjectPage() {
48
41
  }
49
42
  }, [singleConnectionId, selectedConnectionId]);
50
43
 
51
- const selectedConnection: IntegrationConnectionDto | undefined = connections.find(
44
+ const selectedConnection: IntegrationConnection | undefined = connections.find(
52
45
  (connection) => connection.id === effectiveSelectedConnectionId,
53
46
  );
54
47
 
@@ -65,11 +58,11 @@ export function CreateProjectPage() {
65
58
  const [selectedRepositoryId, setSelectedRepositoryId] = useState<string | undefined>();
66
59
  useEffect(() => {
67
60
  if (!selectedRepositoryId && repositories[0]) {
68
- setSelectedRepositoryId(repositories[0].external_repository_id);
61
+ setSelectedRepositoryId(repositories[0].externalRepositoryId);
69
62
  }
70
63
  }, [repositories, selectedRepositoryId]);
71
64
  const selectedRepository = repositories.find(
72
- (repository) => repository.external_repository_id === selectedRepositoryId,
65
+ (repository) => repository.externalRepositoryId === selectedRepositoryId,
73
66
  );
74
67
 
75
68
  const [nameTouched, setNameTouched] = useState(false);
@@ -128,18 +121,15 @@ export function CreateProjectPage() {
128
121
  }
129
122
 
130
123
  try {
131
- const projectBody = createProjectBodySchema.parse({
132
- workspace_id: workspace.id,
124
+ const command: CreateProjectCommand = {
125
+ workspaceId: workspace.id,
133
126
  name: projectName,
134
127
  source: {
135
- connection_id: selectedConnection.id,
136
- external_repository_id: selectedRepository.external_repository_id,
128
+ connectionId: selectedConnection.id,
129
+ externalRepositoryId: selectedRepository.externalRepositoryId,
137
130
  },
138
- });
139
- const project = await createProject.mutateAsync(projectBody);
140
- setWorkspaceProjectExists(queryClient, workspace.id, project);
141
- queryClient.setQueryData(projectsQueryKeys.detail(project.id), project);
142
- await queryClient.invalidateQueries({queryKey: projectsQueryKeys.list(workspace.id)});
131
+ };
132
+ const project = await createProject.mutateAsync(command);
143
133
  toast.success('Project created.');
144
134
  await navigate({
145
135
  to: '/workspaces/$wid/projects/$pid',
@@ -148,11 +138,6 @@ export function CreateProjectPage() {
148
138
  } catch (error) {
149
139
  const copy = projectErrorCopy(error);
150
140
  if (copy.existingProjectId) {
151
- await queryClient.invalidateQueries({
152
- queryKey: projectsQueryKeys.exists(workspace.id),
153
- refetchType: 'active',
154
- });
155
- await queryClient.invalidateQueries({queryKey: projectsQueryKeys.list(workspace.id)});
156
141
  toast.info('Project already exists.');
157
142
  await navigate({
158
143
  to: '/workspaces/$wid/projects/$pid',
@@ -284,7 +269,7 @@ export function CreateProjectPage() {
284
269
 
285
270
  <ProjectSummary
286
271
  connection={selectedConnection}
287
- repositoryName={selectedRepository?.full_name}
272
+ repositoryName={selectedRepository?.fullName}
288
273
  />
289
274
 
290
275
  <form.Field
@@ -333,7 +318,7 @@ function ProjectSummary({
333
318
  connection,
334
319
  repositoryName,
335
320
  }: {
336
- connection: IntegrationConnectionDto | undefined;
321
+ connection: IntegrationConnection | undefined;
337
322
  repositoryName: string | undefined;
338
323
  }) {
339
324
  return (
@@ -356,15 +341,6 @@ function ProjectSummary({
356
341
  );
357
342
  }
358
343
 
359
- function projectNameFromRepository(repositoryId: string): string {
360
- return repositoryId
361
- .trim()
362
- .split(REPOSITORY_NAME_SPLIT_RE)
363
- .filter(Boolean)
364
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
365
- .join(' ');
366
- }
367
-
368
344
  function useDebouncedValue<T>(value: T, delayMs: number): T {
369
345
  const [debounced, setDebounced] = useState(value);
370
346
  useEffect(() => {
@@ -373,14 +349,3 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
373
349
  }, [value, delayMs]);
374
350
  return debounced;
375
351
  }
376
-
377
- function setWorkspaceProjectExists(
378
- queryClient: QueryClient,
379
- workspaceId: string,
380
- project: ProjectResponseDto,
381
- ) {
382
- queryClient.setQueryData<ListProjectsResponseDto>(projectsQueryKeys.exists(workspaceId), {
383
- projects: [project],
384
- next_cursor: null,
385
- });
386
- }
@@ -1,5 +1,6 @@
1
1
  import {ProjectsHubPage} from '#pages/projects-hub-page.js';
2
+ import type {ProjectsSearch} from '#routes/search.js';
2
3
 
3
- export function HomeRouter() {
4
- return <ProjectsHubPage />;
4
+ export function HomeRouter({search = {}}: {search?: ProjectsSearch}) {
5
+ return <ProjectsHubPage search={search.search ?? ''} />;
5
6
  }
@@ -40,7 +40,11 @@ describe('ProjectsHubPage', () => {
40
40
  test('shows and dismisses the model provider reminder when no provider is configured', async () => {
41
41
  const fetchImpl = createHubFetch({
42
42
  projects: jsonResponse({projects: [], next_cursor: null}),
43
- modelProviders: jsonResponse({configs: [], default_provider_id: null}),
43
+ modelProviders: jsonResponse({
44
+ configs: [],
45
+ default_provider_id: null,
46
+ default_harness_id: null,
47
+ }),
44
48
  });
45
49
  configureApiClient({fetchImpl});
46
50
 
@@ -84,14 +88,14 @@ describe('ProjectsHubPage', () => {
84
88
  if (url.searchParams.get('cursor') === 'cursor-1') {
85
89
  return Promise.resolve(
86
90
  jsonResponse({
87
- projects: [projectDto({id: 'project-2', name: 'API'})],
91
+ projects: [projectDto({id: '11111111-1111-4111-8111-111111111113', name: 'API'})],
88
92
  next_cursor: null,
89
93
  }),
90
94
  );
91
95
  }
92
96
  return Promise.resolve(
93
97
  jsonResponse({
94
- projects: [projectDto({id: 'project-1', name: 'Platform'})],
98
+ projects: [projectDto({id: '11111111-1111-4111-8111-111111111112', name: 'Platform'})],
95
99
  next_cursor: 'cursor-1',
96
100
  }),
97
101
  );
@@ -132,7 +136,7 @@ describe('ProjectsHubPage', () => {
132
136
  projects: jsonResponse({
133
137
  projects: [
134
138
  projectDto({
135
- id: 'project-1',
139
+ id: '11111111-1111-4111-8111-111111111112',
136
140
  name: 'Platform',
137
141
  externalRepositoryId: 'github:octo/platform',
138
142
  }),
@@ -171,7 +175,7 @@ describe('ProjectsHubPage', () => {
171
175
  configureApiClient({
172
176
  fetchImpl: createHubFetch({
173
177
  projects: jsonResponse({
174
- projects: [projectDto({id: 'project-1', name: 'Platform'})],
178
+ projects: [projectDto({id: '11111111-1111-4111-8111-111111111112', name: 'Platform'})],
175
179
  next_cursor: null,
176
180
  }),
177
181
  connections: jsonResponse(connectionsDto({lifecycleStatus})),
@@ -190,7 +194,7 @@ describe('ProjectsHubPage', () => {
190
194
  configureApiClient({
191
195
  fetchImpl: createHubFetch({
192
196
  projects: jsonResponse({
193
- projects: [projectDto({id: 'project-1', name: 'Platform'})],
197
+ projects: [projectDto({id: '11111111-1111-4111-8111-111111111112', name: 'Platform'})],
194
198
  next_cursor: null,
195
199
  }),
196
200
  connections: jsonResponse({code: 'server-error'}, {status: 500}),
@@ -218,7 +222,7 @@ describe('ProjectsHubPage', () => {
218
222
 
219
223
  function createHubFetch({
220
224
  projects = jsonResponse({
221
- projects: [projectDto({id: 'project-1', name: 'Platform'})],
225
+ projects: [projectDto({id: '11111111-1111-4111-8111-111111111112', name: 'Platform'})],
222
226
  next_cursor: null,
223
227
  }),
224
228
  connections = jsonResponse(connectionsDto()),
@@ -276,14 +280,15 @@ function modelProviderConfigsDto() {
276
280
  return {
277
281
  configs: [
278
282
  {
283
+ kind: 'builtin',
279
284
  provider_id: 'anthropic',
280
285
  default_model: null,
281
- key_fingerprints: {'credential:api_key': '...abcd'},
282
286
  created_at: new Date().toISOString(),
283
287
  updated_at: new Date().toISOString(),
284
288
  },
285
289
  ],
286
290
  default_provider_id: 'anthropic',
291
+ default_harness_id: null,
287
292
  };
288
293
  }
289
294
 
@@ -1,8 +1,7 @@
1
- import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
2
- import type {ProjectResponseDto} from '@shipfox/api-projects-dto';
3
1
  import {useActiveWorkspace} from '@shipfox/client-auth';
4
2
  import {
5
3
  ConnectionStatusBadge,
4
+ type IntegrationConnection,
6
5
  IntegrationIcon,
7
6
  useIntegrationConnectionsQuery,
8
7
  } from '@shipfox/client-integrations';
@@ -15,17 +14,15 @@ import {Icon} from '@shipfox/react-ui/icon';
15
14
  import {Input} from '@shipfox/react-ui/input';
16
15
  import {Skeleton} from '@shipfox/react-ui/skeleton';
17
16
  import {Header, Text} from '@shipfox/react-ui/typography';
18
- import {Link} from '@tanstack/react-router';
19
- import {useEffect, useState} from 'react';
17
+ import {Link, useNavigate} from '@tanstack/react-router';
20
18
  import {ModelProviderReminderBanner} from '#components/model-provider-reminder-banner.js';
19
+ import type {Project} from '#core/project.js';
21
20
  import {useProjectsInfiniteQuery} from '#hooks/api/projects.js';
22
21
 
23
- export function ProjectsHubPage() {
22
+ export function ProjectsHubPage({search = ''}: {search?: string}) {
24
23
  const workspace = useActiveWorkspace();
25
- const [searchInput, setSearchInput] = useState('');
26
- const trimmedInput = searchInput.trim();
27
- const debouncedSearch = useDebouncedValue(trimmedInput, 250);
28
- const query = useProjectsInfiniteQuery(workspace.id, debouncedSearch || undefined);
24
+ const navigate = useNavigate();
25
+ const query = useProjectsInfiniteQuery(workspace.id, search || undefined);
29
26
  const projects = query.data?.pages.flatMap((page) => page.projects) ?? [];
30
27
 
31
28
  // The provider logo and connection health live on the integration connection,
@@ -35,13 +32,11 @@ export function ProjectsHubPage() {
35
32
  projects.length > 0 ? workspace.id : undefined,
36
33
  );
37
34
  const connectionsById = new Map(
38
- (connectionsQuery.data?.connections ?? []).map((connection) => [connection.id, connection]),
35
+ (connectionsQuery.data ?? []).map((connection) => [connection.id, connection]),
39
36
  );
40
37
 
41
38
  const isInitialLoading = query.isPending;
42
- const isDebouncePending = trimmedInput !== debouncedSearch;
43
- const isSearching =
44
- Boolean(trimmedInput) && (isDebouncePending || (query.isFetching && !query.isFetchingNextPage));
39
+ const isSearching = Boolean(search) && query.isFetching && !query.isFetchingNextPage;
45
40
  const hasNoData = !query.data;
46
41
 
47
42
  return (
@@ -53,8 +48,11 @@ export function ProjectsHubPage() {
53
48
  <div className="flex items-center gap-12 max-[640px]:flex-col max-[640px]:items-stretch">
54
49
  <Input
55
50
  type="search"
56
- value={searchInput}
57
- onChange={(event) => setSearchInput(event.target.value)}
51
+ value={search}
52
+ onChange={(event) => {
53
+ const next = event.target.value.trim();
54
+ navigate({search: (next ? {search: next} : {}) as never, replace: true});
55
+ }}
58
56
  placeholder="Search projects…"
59
57
  aria-label="Search projects"
60
58
  className="flex-1"
@@ -80,18 +78,19 @@ export function ProjectsHubPage() {
80
78
 
81
79
  <ModelProviderReminderBanner workspaceId={workspace.id} />
82
80
 
83
- {isInitialLoading || (debouncedSearch && hasNoData && query.isFetching) ? (
84
- <ProjectsSkeleton />
85
- ) : null}
81
+ {isInitialLoading || (search && hasNoData && query.isFetching) ? <ProjectsSkeleton /> : null}
86
82
 
87
83
  {query.isError && hasNoData ? <QueryLoadError query={query} subject="projects" /> : null}
88
84
 
89
- {!isInitialLoading && !query.isError && projects.length === 0 && !debouncedSearch ? (
85
+ {!isInitialLoading && !query.isError && projects.length === 0 && !search ? (
90
86
  <EmptyProjects workspaceId={workspace.id} />
91
87
  ) : null}
92
88
 
93
- {!query.isFetching && !query.isError && projects.length === 0 && debouncedSearch ? (
94
- <NoSearchResults search={debouncedSearch} onClear={() => setSearchInput('')} />
89
+ {!query.isFetching && !query.isError && projects.length === 0 && search ? (
90
+ <NoSearchResults
91
+ search={search}
92
+ onClear={() => navigate({search: {} as never, replace: true})}
93
+ />
95
94
  ) : null}
96
95
 
97
96
  {projects.length > 0 ? (
@@ -100,7 +99,7 @@ export function ProjectsHubPage() {
100
99
  {projects.map((project) => (
101
100
  <ProjectCard
102
101
  project={project}
103
- connection={connectionsById.get(project.source.connection_id)}
102
+ connection={connectionsById.get(project.source.connectionId)}
104
103
  connectionsResolved={connectionsQuery.isSuccess}
105
104
  connectionsSettled={connectionsQuery.isSuccess || connectionsQuery.isError}
106
105
  key={project.id}
@@ -132,15 +131,6 @@ export function ProjectsHubPage() {
132
131
  );
133
132
  }
134
133
 
135
- function useDebouncedValue<T>(value: T, delayMs: number): T {
136
- const [debounced, setDebounced] = useState(value);
137
- useEffect(() => {
138
- const handle = setTimeout(() => setDebounced(value), delayMs);
139
- return () => clearTimeout(handle);
140
- }, [value, delayMs]);
141
- return debounced;
142
- }
143
-
144
134
  function ProjectsSkeleton() {
145
135
  return (
146
136
  <ul
@@ -201,8 +191,8 @@ function ProjectCard({
201
191
  connectionsSettled,
202
192
  workspaceId,
203
193
  }: {
204
- project: ProjectResponseDto;
205
- connection: IntegrationConnectionDto | undefined;
194
+ project: Project;
195
+ connection: IntegrationConnection | undefined;
206
196
  connectionsResolved: boolean;
207
197
  connectionsSettled: boolean;
208
198
  workspaceId: string;
@@ -210,7 +200,7 @@ function ProjectCard({
210
200
  // On a resolved fetch, `active` carries no badge while a missing connection
211
201
  // reads as an error so a broken source is still flagged. An unresolved or
212
202
  // failed fetch shows nothing, so a fetch failure never flags every card.
213
- const status = connectionsResolved ? (connection?.lifecycle_status ?? 'error') : undefined;
203
+ const status = connectionsResolved ? (connection?.lifecycleStatus ?? 'error') : undefined;
214
204
 
215
205
  return (
216
206
  <li>
@@ -1,4 +1,8 @@
1
- import {defineRoute} from '@shipfox/client-shell/runtime';
1
+ import {defineRoute, useRouteSearch} from '@shipfox/client-shell/runtime';
2
2
  import {HomeRouter} from '#pages/home-router.js';
3
+ import {validateProjectsSearch} from './search.js';
3
4
 
4
- export default defineRoute({component: HomeRouter});
5
+ export default defineRoute({
6
+ validateSearch: validateProjectsSearch,
7
+ component: () => <HomeRouter search={useRouteSearch(validateProjectsSearch)} />,
8
+ });
@@ -0,0 +1,14 @@
1
+ import {projectRouteParams} from './inputs.js';
2
+
3
+ describe('project route inputs', () => {
4
+ it('requires both workspace and project ids', () => {
5
+ expect(projectRouteParams({wid: 'workspace-1', pid: 'project-1'})).toEqual({
6
+ wid: 'workspace-1',
7
+ pid: 'project-1',
8
+ });
9
+ expect(() => projectRouteParams({wid: 'workspace-1'})).toThrow(
10
+ 'Project route is missing required path parameters.',
11
+ );
12
+ expect(() => projectRouteParams({wid: ['workspace-1'], pid: 'project-1'})).toThrow();
13
+ });
14
+ });