@shipfox/client-projects 24.0.0 → 26.0.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-projects",
3
3
  "license": "MIT",
4
- "version": "24.0.0",
4
+ "version": "26.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -28,16 +28,16 @@
28
28
  "@swc/helpers": "^0.5.17",
29
29
  "@tanstack/react-form": "^1.32.0",
30
30
  "@shipfox/api-common-dto": "15.0.0",
31
- "@shipfox/api-definitions-dto": "15.0.0",
32
- "@shipfox/api-integration-core-dto": "15.0.0",
31
+ "@shipfox/api-definitions-dto": "16.1.0",
32
+ "@shipfox/api-integration-core-dto": "16.0.0",
33
33
  "@shipfox/api-projects-dto": "15.0.0",
34
- "@shipfox/client-agent": "24.0.0",
34
+ "@shipfox/client-agent": "26.0.0",
35
35
  "@shipfox/client-api": "6.0.1",
36
- "@shipfox/client-auth": "24.0.0",
37
- "@shipfox/client-integrations": "24.0.0",
38
- "@shipfox/client-shell": "24.0.0",
39
- "@shipfox/client-ui": "24.0.0",
40
- "@shipfox/react-ui": "2.2.0"
36
+ "@shipfox/client-auth": "26.0.0",
37
+ "@shipfox/client-integrations": "26.0.0",
38
+ "@shipfox/client-ui": "26.0.0",
39
+ "@shipfox/react-ui": "2.3.0",
40
+ "@shipfox/client-shell": "26.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@tanstack/react-query": "^5.101.0",
@@ -4,9 +4,11 @@ import {
4
4
  createProject,
5
5
  isProjectSlugAvailable,
6
6
  listProjects,
7
+ projectExistenceQueryOptions,
7
8
  projectSlugQueryOptions,
8
9
  projectsInfiniteQueryOptions,
9
10
  projectsQueryKeys,
11
+ readWorkspaceHasNoProject,
10
12
  resolveProjectSlug,
11
13
  updateProject,
12
14
  } from './projects.js';
@@ -46,6 +48,123 @@ describe('listProjects', () => {
46
48
  });
47
49
  });
48
50
 
51
+ describe('readWorkspaceHasNoProject', () => {
52
+ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
53
+
54
+ beforeEach(() => {
55
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
56
+ });
57
+
58
+ test('returns true when the fresh existence read returns an empty list', async () => {
59
+ configureApiClient({
60
+ fetchImpl: vi.fn().mockResolvedValue(jsonResponse({projects: [], next_cursor: null})),
61
+ });
62
+
63
+ await expect(
64
+ readWorkspaceHasNoProject({queryClient: new QueryClient(), workspaceId: WORKSPACE_ID}),
65
+ ).resolves.toBe(true);
66
+ });
67
+
68
+ test('returns false when the fresh existence read returns a project', async () => {
69
+ configureApiClient({
70
+ fetchImpl: vi.fn().mockResolvedValue(
71
+ jsonResponse({
72
+ projects: [
73
+ {
74
+ id: '44444444-4444-4444-8444-444444444444',
75
+ workspace_id: WORKSPACE_ID,
76
+ name: 'Platform',
77
+ slug: 'platform',
78
+ source: {
79
+ connection_id: '33333333-3333-4333-8333-333333333333',
80
+ external_repository_id: 'platform',
81
+ },
82
+ created_at: '2026-05-07T01:00:00.000Z',
83
+ updated_at: '2026-05-07T01:00:00.000Z',
84
+ },
85
+ ],
86
+ next_cursor: null,
87
+ }),
88
+ ),
89
+ });
90
+
91
+ await expect(
92
+ readWorkspaceHasNoProject({queryClient: new QueryClient(), workspaceId: WORKSPACE_ID}),
93
+ ).resolves.toBe(false);
94
+ });
95
+
96
+ test('falls back to a cached empty list when the fresh read fails', async () => {
97
+ configureApiClient({
98
+ fetchImpl: vi
99
+ .fn()
100
+ .mockResolvedValue(jsonResponse({message: 'upstream unavailable'}, {status: 500})),
101
+ });
102
+ const queryClient = new QueryClient();
103
+ queryClient.setQueryData(projectExistenceQueryOptions(WORKSPACE_ID).queryKey, {
104
+ projects: [],
105
+ nextCursor: null,
106
+ });
107
+
108
+ await expect(readWorkspaceHasNoProject({queryClient, workspaceId: WORKSPACE_ID})).resolves.toBe(
109
+ true,
110
+ );
111
+ });
112
+
113
+ test('skips the fresh read when a cached non-empty list rules out the first-project path', async () => {
114
+ const fetchImpl = vi.fn().mockRejectedValue(new Error('unexpected fetch'));
115
+ configureApiClient({fetchImpl});
116
+ const queryClient = new QueryClient();
117
+ queryClient.setQueryData(projectExistenceQueryOptions(WORKSPACE_ID).queryKey, {
118
+ projects: [
119
+ {
120
+ id: '55555555-5555-4555-8555-555555555555',
121
+ workspaceId: WORKSPACE_ID,
122
+ name: 'Platform',
123
+ slug: 'platform',
124
+ source: {connectionId: 'c', externalRepositoryId: 'platform'},
125
+ createdAt: new Date().toISOString(),
126
+ updatedAt: new Date().toISOString(),
127
+ },
128
+ ],
129
+ nextCursor: null,
130
+ });
131
+
132
+ await expect(readWorkspaceHasNoProject({queryClient, workspaceId: WORKSPACE_ID})).resolves.toBe(
133
+ false,
134
+ );
135
+ // The landing is already decided, so no existence request is issued and no
136
+ // failure is reported for an established workspace.
137
+ expect(fetchImpl).not.toHaveBeenCalled();
138
+ });
139
+
140
+ test('returns false when the fresh read fails without any cache', async () => {
141
+ configureApiClient({
142
+ fetchImpl: vi
143
+ .fn()
144
+ .mockResolvedValue(jsonResponse({message: 'upstream unavailable'}, {status: 500})),
145
+ });
146
+
147
+ await expect(
148
+ readWorkspaceHasNoProject({queryClient: new QueryClient(), workspaceId: WORKSPACE_ID}),
149
+ ).resolves.toBe(false);
150
+ });
151
+
152
+ test('reports the failed fresh read to the error-reporting pipeline', async () => {
153
+ const reportErrorSpy = vi.fn();
154
+ vi.stubGlobal('reportError', reportErrorSpy);
155
+ configureApiClient({
156
+ fetchImpl: vi
157
+ .fn()
158
+ .mockResolvedValue(jsonResponse({message: 'upstream unavailable'}, {status: 500})),
159
+ });
160
+
161
+ await readWorkspaceHasNoProject({queryClient: new QueryClient(), workspaceId: WORKSPACE_ID});
162
+
163
+ expect(reportErrorSpy).toHaveBeenCalledOnce();
164
+ vi.unstubAllGlobals();
165
+ });
166
+ });
167
+
49
168
  describe('createProject', () => {
50
169
  beforeEach(() => {
51
170
  configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
@@ -305,6 +305,52 @@ export function projectExistenceQueryOptions(
305
305
  });
306
306
  }
307
307
 
308
+ export async function readWorkspaceHasNoProject({
309
+ queryClient,
310
+ workspaceId,
311
+ }: {
312
+ queryClient: QueryClient;
313
+ workspaceId: string;
314
+ }): Promise<boolean> {
315
+ const options = projectExistenceQueryOptions(workspaceId);
316
+ // A cached non-empty list already rules out the first-project landing, so
317
+ // skip the fresh read entirely: the snapshot only steers navigation for the
318
+ // empty-workspace case, and a failed fresh read here would otherwise report
319
+ // a global error on every create in an established workspace without
320
+ // changing the landing.
321
+ const cached = queryClient.getQueryData<ProjectList>(options.queryKey);
322
+ if (cached !== undefined && cached.projects.length > 0) return false;
323
+ try {
324
+ // The landing decision must not trust fresh-but-stale existence data
325
+ // (e.g. a project created in another tab inside the 30s staleTime window),
326
+ // so force a fresh read for this check. The snapshot also runs without
327
+ // retry so a slow or failing existence endpoint cannot stall the submit
328
+ // handler on the app-default retry backoff; the catch falls back to cache.
329
+ const data = await queryClient.fetchQuery({...options, staleTime: 0, retry: false});
330
+ return data.projects.length === 0;
331
+ } catch (error) {
332
+ reportExistenceReadFailure(error);
333
+ const fallback = queryClient.getQueryData<ProjectList>(options.queryKey);
334
+ if (fallback !== undefined) return fallback.projects.length === 0;
335
+ // Unknown existence keeps the existing project navigation.
336
+ return false;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * The existence snapshot is a best-effort pre-flight read: a failure must not
342
+ * block project creation, but it still reaches the global error-reporting
343
+ * pipeline so a degrading existence endpoint does not silently misroute the
344
+ * first-project landing.
345
+ */
346
+ function reportExistenceReadFailure(error: unknown): void {
347
+ globalThis.reportError?.(
348
+ new Error('Failed to read workspace project existence for the first-project landing.', {
349
+ cause: error,
350
+ }),
351
+ );
352
+ }
353
+
308
354
  export function projectQueryOptions(projectId: string | undefined): ProjectDetailQueryOptions {
309
355
  return queryOptions({
310
356
  queryKey: projectId
@@ -2,7 +2,7 @@ import {configureApiClient} from '@shipfox/client-api';
2
2
  import {QueryClient} from '@tanstack/react-query';
3
3
  import {fireEvent, screen, waitFor, within} from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
- import {projectsQueryKeys} from '#hooks/api/projects.js';
5
+ import {projectExistenceQueryOptions, projectsQueryKeys} from '#hooks/api/projects.js';
6
6
  import {
7
7
  jsonResponse,
8
8
  PROJECT_TEST_WID,
@@ -22,7 +22,7 @@ describe('CreateProjectPage', () => {
22
22
  window.sessionStorage.clear();
23
23
  });
24
24
 
25
- test('with a single connection: pre-selects, renders repos, creates a project', async () => {
25
+ test('with a single connection: pre-selects, renders repos, and lands the first project on the workspace home', async () => {
26
26
  let createProjectBody: unknown;
27
27
  const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
28
28
  const request = input as Request;
@@ -32,6 +32,22 @@ describe('CreateProjectPage', () => {
32
32
  if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
33
33
  return jsonResponse({repositories: [repositoryDto()], next_cursor: null});
34
34
  }
35
+ if (request.url.includes('/projects?')) {
36
+ const url = new URL(request.url);
37
+ // The pre-create existence snapshot (limit=1) sees an empty workspace;
38
+ // the home list (limit=50) sees the created project, so the home does
39
+ // not fall back to the pre-create empty state.
40
+ if (url.searchParams.get('search')) {
41
+ return jsonResponse({projects: [], next_cursor: null});
42
+ }
43
+ if (url.searchParams.get('limit') === '1') {
44
+ return jsonResponse({projects: [], next_cursor: null});
45
+ }
46
+ return jsonResponse({
47
+ projects: [projectDto({id: '44444444-4444-4444-8444-444444444444'})],
48
+ next_cursor: null,
49
+ });
50
+ }
35
51
  if (request.url.endsWith('/projects') && request.method === 'POST') {
36
52
  createProjectBody = await request.json();
37
53
  return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
@@ -76,7 +92,13 @@ describe('CreateProjectPage', () => {
76
92
  expect(screen.getByText('/w/acme/p/launchpad')).toBeInTheDocument();
77
93
  fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
78
94
 
79
- expect(await screen.findByRole('heading', {name: 'Runs'})).toBeInTheDocument();
95
+ // The first project lands on the workspace home, where the Get-started
96
+ // panel is the first panel, the just-created project is listed instead of
97
+ // the pre-create empty state, and the project detail is not shown.
98
+ expect(await screen.findByRole('searchbox', {name: 'Search projects'})).toBeInTheDocument();
99
+ expect(await screen.findByText('Project Detail')).toBeInTheDocument();
100
+ expect(screen.queryByText('Create your first project')).not.toBeInTheDocument();
101
+ expect(screen.queryByRole('heading', {name: 'Runs'})).not.toBeInTheDocument();
80
102
  expect(createProjectBody).toEqual({
81
103
  workspace_id: PROJECT_TEST_WID,
82
104
  name: 'Launch Pad',
@@ -88,6 +110,56 @@ describe('CreateProjectPage', () => {
88
110
  });
89
111
  }, 10_000);
90
112
 
113
+ test('does not double-submit while the existence snapshot is in flight', async () => {
114
+ let releaseExistenceRead: (() => void) | undefined;
115
+ const existenceReadGate = new Promise<void>((resolve) => {
116
+ releaseExistenceRead = resolve;
117
+ });
118
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
119
+ const request = input as Request;
120
+ if (request.url.includes('/integration-connections?')) {
121
+ return jsonResponse({connections: [connectionDto()]});
122
+ }
123
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
124
+ return jsonResponse({repositories: [repositoryDto()], next_cursor: null});
125
+ }
126
+ if (request.url.includes('/projects?')) {
127
+ const url = new URL(request.url);
128
+ if (url.searchParams.get('limit') === '1') {
129
+ await existenceReadGate;
130
+ return jsonResponse({projects: [], next_cursor: null});
131
+ }
132
+ return jsonResponse({
133
+ projects: [projectDto({id: '44444444-4444-4444-8444-444444444444'})],
134
+ next_cursor: null,
135
+ });
136
+ }
137
+ if (request.url.endsWith('/projects') && request.method === 'POST') {
138
+ return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
139
+ }
140
+ return jsonResponse({});
141
+ });
142
+ configureApiClient({fetchImpl});
143
+
144
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
145
+ expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
146
+
147
+ const createButton = screen.getByRole('button', {name: 'Create project'});
148
+ fireEvent.click(createButton);
149
+ fireEvent.click(createButton);
150
+
151
+ // The button stays disabled while the existence snapshot runs, so a second
152
+ // click cannot start a duplicate create.
153
+ await waitFor(() =>
154
+ expect(screen.getByRole('button', {name: 'Create project'})).toBeDisabled(),
155
+ );
156
+ releaseExistenceRead?.();
157
+
158
+ // Exactly one POST and a deterministic landing on the workspace home.
159
+ expect(await screen.findByRole('searchbox', {name: 'Search projects'})).toBeInTheDocument();
160
+ expect(projectPostCount(fetchImpl)).toBe(1);
161
+ }, 10_000);
162
+
91
163
  test('does not render an empty source panel when connections fail to load', async () => {
92
164
  const fetchImpl = vi.fn((input: RequestInfo | URL) => {
93
165
  const request = input as Request;
@@ -334,6 +406,56 @@ describe('CreateProjectPage', () => {
334
406
  expect(projectPostCount(fetchImpl)).toBe(0);
335
407
  });
336
408
 
409
+ test('with an existing project: keeps navigating to the created project', async () => {
410
+ let createProjectBody: unknown;
411
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
412
+ const request = input as Request;
413
+ if (request.url.includes('/integration-connections?')) {
414
+ return jsonResponse({connections: [connectionDto()]});
415
+ }
416
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
417
+ return jsonResponse({repositories: [repositoryDto()], next_cursor: null});
418
+ }
419
+ if (request.url.endsWith('/projects') && request.method === 'POST') {
420
+ createProjectBody = await request.json();
421
+ return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
422
+ }
423
+ return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
424
+ });
425
+ configureApiClient({fetchImpl});
426
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
427
+ queryClient.setQueryData(projectExistenceQueryOptions(PROJECT_TEST_WID).queryKey, {
428
+ projects: [
429
+ {
430
+ id: '55555555-5555-4555-8555-555555555555',
431
+ workspaceId: PROJECT_TEST_WID,
432
+ name: 'Platform',
433
+ slug: 'platform',
434
+ source: {connectionId: CONNECTION_ID, externalRepositoryId: 'platform'},
435
+ createdAt: new Date().toISOString(),
436
+ updatedAt: new Date().toISOString(),
437
+ },
438
+ ],
439
+ nextCursor: null,
440
+ });
441
+
442
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />, queryClient);
443
+ expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
444
+ fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
445
+
446
+ // A workspace that already has a project keeps landing on the project.
447
+ expect(await screen.findByRole('heading', {name: 'Runs'})).toBeInTheDocument();
448
+ expect(createProjectBody).toEqual({
449
+ workspace_id: PROJECT_TEST_WID,
450
+ name: 'Platform',
451
+ slug: 'platform',
452
+ source: {
453
+ connection_id: CONNECTION_ID,
454
+ external_repository_id: 'platform',
455
+ },
456
+ });
457
+ }, 10_000);
458
+
337
459
  test('with a single connection: shows workspace-scoped "Add another integration" link', async () => {
338
460
  configureApiClient({
339
461
  fetchImpl: vi.fn((input: RequestInfo | URL) => {
@@ -355,33 +477,6 @@ describe('CreateProjectPage', () => {
355
477
  expect(link).toHaveAttribute('href', `/w/${PROJECT_TEST_WSLUG}/integrations`);
356
478
  });
357
479
 
358
- test('shows the model provider reminder on project creation when no provider is configured', async () => {
359
- const fetchImpl = vi.fn((input: RequestInfo | URL) => {
360
- const request = input as Request;
361
- if (request.url.endsWith('/agent/model-providers')) {
362
- return Promise.resolve(
363
- jsonResponse({configs: [], default_provider_id: null, default_harness_id: null}),
364
- );
365
- }
366
- if (request.url.includes('/integration-connections?')) {
367
- return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
368
- }
369
- if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
370
- return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
371
- }
372
- return Promise.resolve(jsonResponse({}));
373
- });
374
- configureApiClient({fetchImpl});
375
-
376
- renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
377
-
378
- expect(await screen.findByText('Finish setting up a model provider')).toBeInTheDocument();
379
- expect(screen.getByRole('link', {name: 'Agents'})).toHaveAttribute(
380
- 'href',
381
- `/w/${PROJECT_TEST_WSLUG}/settings/agents`,
382
- );
383
- });
384
-
385
480
  test('navigates to the existing project for duplicate recovery', async () => {
386
481
  const fetchImpl = vi.fn((input: RequestInfo | URL) => {
387
482
  const request = input as Request;
@@ -19,12 +19,18 @@ import {Panel, PanelActions, PanelBody, PanelHeader, PanelTitle} from '@shipfox/
19
19
  import {toast} from '@shipfox/react-ui/toast';
20
20
  import {Header, Text} from '@shipfox/react-ui/typography';
21
21
  import {useForm} from '@tanstack/react-form';
22
+ import {type InfiniteData, useQueryClient} from '@tanstack/react-query';
22
23
  import {Link, Navigate, useNavigate} from '@tanstack/react-router';
23
24
  import {useEffect, useRef, useState} from 'react';
24
- import {ModelProviderReminderBanner} from '#components/model-provider-reminder-banner.js';
25
- import {type CreateProjectCommand, projectNameFromRepository} from '#core/project.js';
25
+ import {
26
+ type CreateProjectCommand,
27
+ type ProjectList,
28
+ projectNameFromRepository,
29
+ } from '#core/project.js';
26
30
  import {
27
31
  getProject,
32
+ projectsInfiniteQueryOptions,
33
+ readWorkspaceHasNoProject,
28
34
  useCreateProjectMutation,
29
35
  useProjectSlugAvailability,
30
36
  } from '#hooks/api/projects.js';
@@ -37,8 +43,13 @@ function isSlugValid(value: string): boolean {
37
43
  export function CreateProjectPage() {
38
44
  const workspace = useMaybeActiveWorkspace();
39
45
  const navigate = useNavigate();
46
+ const queryClient = useQueryClient();
40
47
  const createProject = useCreateProjectMutation();
41
48
  const errorRef = useRef<HTMLDivElement>(null);
49
+ // The submit flow awaits an existence snapshot before the create mutation, so
50
+ // the pending state must cover the whole handler, not just the mutation.
51
+ const submittingRef = useRef(false);
52
+ const [submitting, setSubmitting] = useState(false);
42
53
 
43
54
  const connectionsQuery = useSourceConnectionsQuery(workspace?.id);
44
55
  const connections = connectionsQuery.data ?? [];
@@ -150,8 +161,17 @@ export function CreateProjectPage() {
150
161
  errorRef.current?.focus();
151
162
  return;
152
163
  }
164
+ if (submittingRef.current) return;
165
+ submittingRef.current = true;
166
+ setSubmitting(true);
153
167
 
154
168
  try {
169
+ // Snapshot project existence before the mutation: after a successful
170
+ // create the workspace has one project and "first" is already false.
171
+ const wasFirstProject = await readWorkspaceHasNoProject({
172
+ queryClient,
173
+ workspaceId: workspace.id,
174
+ });
155
175
  const command: CreateProjectCommand = {
156
176
  workspaceId: workspace.id,
157
177
  name: projectName,
@@ -163,6 +183,35 @@ export function CreateProjectPage() {
163
183
  };
164
184
  const project = await createProject.mutateAsync(command);
165
185
  toast.success('Project created.');
186
+ if (wasFirstProject) {
187
+ // The setup checklist panel is the first thing on the home, so the
188
+ // first project lands where the Get-started guide lives. Seed the
189
+ // workspace list so the home does not re-render the pre-create empty
190
+ // state, then fetch the authoritative list: the seeded entry has no
191
+ // queryFn of its own (no observer mounted it), so refetching it would
192
+ // fail and a project created concurrently (another tab, import, or
193
+ // API) would stay hidden for the stale window. Fetching with
194
+ // staleTime: 0 forces a fresh read that replaces the seed; a failed
195
+ // fetch is swallowed so the seeded project remains the fallback and
196
+ // the home never shows a stale empty state.
197
+ const listQueryKey = projectsInfiniteQueryOptions(workspace.id).queryKey;
198
+ queryClient.setQueryData<InfiniteData<ProjectList, string | undefined>>(listQueryKey, {
199
+ pages: [{projects: [project], nextCursor: null}],
200
+ pageParams: [undefined],
201
+ });
202
+ await queryClient
203
+ .fetchInfiniteQuery({
204
+ ...projectsInfiniteQueryOptions(workspace.id),
205
+ staleTime: 0,
206
+ retry: false,
207
+ })
208
+ .catch(() => undefined);
209
+ await navigate({
210
+ to: '/w/$workspaceSlug',
211
+ params: {workspaceSlug: workspace.slug},
212
+ });
213
+ return;
214
+ }
166
215
  await navigate({
167
216
  to: '/w/$workspaceSlug/p/$projectSlug',
168
217
  params: {workspaceSlug: workspace.slug, projectSlug: project.slug},
@@ -191,6 +240,9 @@ export function CreateProjectPage() {
191
240
  }
192
241
  setFormError(`${copy.title}: ${copy.message}`);
193
242
  requestAnimationFrame(() => errorRef.current?.focus());
243
+ } finally {
244
+ submittingRef.current = false;
245
+ setSubmitting(false);
194
246
  }
195
247
  }
196
248
 
@@ -205,8 +257,6 @@ export function CreateProjectPage() {
205
257
  Create project
206
258
  </Header>
207
259
 
208
- <ModelProviderReminderBanner workspaceId={workspace.id} />
209
-
210
260
  {connectionsQuery.isError ? (
211
261
  <Callout role="alert" type="error">
212
262
  <div className="flex w-full flex-wrap items-center justify-between gap-cluster">
@@ -399,7 +449,7 @@ export function CreateProjectPage() {
399
449
  <Button
400
450
  type="submit"
401
451
  iconRight="chevronRight"
402
- isLoading={createProject.isPending}
452
+ isLoading={submitting}
403
453
  disabled={!selectedConnection || !selectedRepository}
404
454
  className="w-full"
405
455
  >
@@ -25,7 +25,6 @@ describe('ProjectsHubPage', () => {
25
25
  configureApiClient({
26
26
  fetchImpl: createHubFetch({
27
27
  projects: jsonResponse({projects: [], next_cursor: null}),
28
- modelProviders: jsonResponse(modelProviderConfigsDto()),
29
28
  }),
30
29
  });
31
30
 
@@ -46,13 +45,16 @@ describe('ProjectsHubPage', () => {
46
45
  configureApiClient({
47
46
  fetchImpl: createHubFetch({
48
47
  projects: jsonResponse({projects: [], next_cursor: null}),
49
- modelProviders: jsonResponse(modelProviderConfigsDto()),
50
48
  }),
51
49
  });
52
50
 
53
51
  renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
54
52
 
55
53
  expect(await screen.findByText('Create your first project')).toBeInTheDocument();
54
+ // Without the slot, the hub renders no checklist panel above the projects.
55
+ expect(
56
+ screen.queryByRole('region', {name: 'Workspace setup checklist'}),
57
+ ).not.toBeInTheDocument();
56
58
  const projectsRegion = screen.getByRole('region', {name: 'Projects'});
57
59
  expect(projectsRegion.querySelectorAll('[data-slot="panel"]')).toHaveLength(1);
58
60
  const panelHeader = projectsRegion.querySelector<HTMLElement>('[data-slot="panel-header"]');
@@ -74,50 +76,9 @@ describe('ProjectsHubPage', () => {
74
76
  ).toBe(WORKSPACE_PROJECTS_NEW_HREF);
75
77
  });
76
78
 
77
- test('shows and dismisses the model provider reminder when no provider is configured', async () => {
78
- const fetchImpl = createHubFetch({
79
- projects: jsonResponse({projects: [], next_cursor: null}),
80
- modelProviders: jsonResponse({
81
- configs: [],
82
- default_provider_id: null,
83
- default_harness_id: null,
84
- }),
85
- });
86
- configureApiClient({fetchImpl});
87
-
88
- renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
89
-
90
- expect(await screen.findByText('Finish setting up a model provider')).toBeInTheDocument();
91
- expect(screen.getByRole('link', {name: 'Agents'})).toHaveAttribute(
92
- 'href',
93
- `/w/${PROJECT_TEST_WSLUG}/settings/agents`,
94
- );
95
- fireEvent.click(screen.getByRole('button', {name: 'Close'}));
96
-
97
- await waitFor(() => {
98
- expect(screen.queryByText('Finish setting up a model provider')).not.toBeInTheDocument();
99
- });
100
- });
101
-
102
- test('hides the model provider reminder when a provider is configured', async () => {
103
- const fetchImpl = createHubFetch({
104
- projects: jsonResponse({projects: [], next_cursor: null}),
105
- modelProviders: jsonResponse(modelProviderConfigsDto()),
106
- });
107
- configureApiClient({fetchImpl});
108
-
109
- renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
110
-
111
- expect(await screen.findByText('Create your first project')).toBeInTheDocument();
112
- expect(screen.queryByText('Finish setting up a model provider')).not.toBeInTheDocument();
113
- });
114
-
115
79
  test('renders projects and loads the next cursor page', async () => {
116
80
  const fetchImpl = vi.fn((input: RequestInfo | URL) => {
117
81
  const url = new URL(requestInputUrl(input));
118
- if (url.pathname.endsWith('/agent/model-providers')) {
119
- return Promise.resolve(jsonResponse(modelProviderConfigsDto()));
120
- }
121
82
  if (url.pathname === '/integration-connections') {
122
83
  return Promise.resolve(jsonResponse(connectionsDto()));
123
84
  }
@@ -188,9 +149,6 @@ describe('ProjectsHubPage', () => {
188
149
  let cursorRequests = 0;
189
150
  const fetchImpl = vi.fn((input: RequestInfo | URL) => {
190
151
  const url = new URL(requestInputUrl(input));
191
- if (url.pathname.endsWith('/agent/model-providers')) {
192
- return Promise.resolve(jsonResponse(modelProviderConfigsDto()));
193
- }
194
152
  if (url.pathname === '/integration-connections') {
195
153
  return Promise.resolve(jsonResponse(connectionsDto()));
196
154
  }
@@ -336,17 +294,12 @@ function createHubFetch({
336
294
  next_cursor: null,
337
295
  }),
338
296
  connections = jsonResponse(connectionsDto()),
339
- modelProviders = jsonResponse(modelProviderConfigsDto()),
340
297
  }: {
341
298
  projects?: Response;
342
299
  connections?: Response;
343
- modelProviders?: Response;
344
300
  } = {}) {
345
301
  return vi.fn((input: RequestInfo | URL) => {
346
302
  const url = new URL(requestInputUrl(input));
347
- if (url.pathname.endsWith('/agent/model-providers')) {
348
- return Promise.resolve(modelProviders.clone());
349
- }
350
303
  if (url.pathname === '/integration-connections') {
351
304
  return Promise.resolve(connections.clone());
352
305
  }
@@ -387,22 +340,6 @@ function projectDto({
387
340
  };
388
341
  }
389
342
 
390
- function modelProviderConfigsDto() {
391
- return {
392
- configs: [
393
- {
394
- kind: 'builtin',
395
- provider_id: 'anthropic',
396
- default_model: null,
397
- created_at: new Date().toISOString(),
398
- updated_at: new Date().toISOString(),
399
- },
400
- ],
401
- default_provider_id: 'anthropic',
402
- default_harness_id: null,
403
- };
404
- }
405
-
406
343
  function connectionsDto({
407
344
  lifecycleStatus = 'active',
408
345
  id = CONNECTION_ID,
@@ -24,7 +24,6 @@ import {
24
24
  import {Skeleton} from '@shipfox/react-ui/skeleton';
25
25
  import {Text} from '@shipfox/react-ui/typography';
26
26
  import {Link, useNavigate} from '@tanstack/react-router';
27
- import {ModelProviderReminderBanner} from '#components/model-provider-reminder-banner.js';
28
27
  import type {Project} from '#core/project.js';
29
28
  import {useProjectsInfiniteQuery} from '#hooks/api/projects.js';
30
29
 
@@ -52,7 +51,6 @@ export function ProjectsHubPage({search = ''}: {search?: string}) {
52
51
  return (
53
52
  <div className="flex w-full flex-col gap-section">
54
53
  {WorkspaceSetupChecklist ? <WorkspaceSetupChecklist /> : null}
55
- <ModelProviderReminderBanner workspaceId={workspace.id} />
56
54
 
57
55
  <section aria-label="Projects">
58
56
  <Panel>