From 308dbd3eab538db90d4839812bb60d0ddd8478f5 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:20:16 +0200 Subject: [PATCH 1/2] fix(clients): disable add project while disconnected --- .../features/projects/AddProjectScreen.tsx | 60 +++++++++++++---- .../features/threads/NewTaskRouteScreen.tsx | 30 +++++---- apps/web/src/components/CommandPalette.tsx | 66 +++++++++++++++++-- .../src/operations/projects.test.ts | 10 +++ .../client-runtime/src/operations/projects.ts | 7 ++ 5 files changed, 146 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index eef40382b24..98a20fa284d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -4,12 +4,17 @@ import { addProjectRemoteSourceProvider, buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, + canCreateProjectInEnvironment, findExistingAddProject, getAddProjectInitialQuery, resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, } from "@t3tools/client-runtime/operations/projects"; +import { + connectionStatusText, + type EnvironmentConnectionPhase, +} from "@t3tools/client-runtime/connection"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, @@ -46,6 +51,7 @@ import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { + useRemoteConnectionStatus, useRemoteEnvironmentRuntime, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; @@ -55,6 +61,9 @@ interface EnvironmentOption { readonly label: string; readonly platform: string; readonly baseDirectory: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; } const environmentOptionOrder = Order.mapInput( @@ -286,19 +295,27 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { function useEnvironmentOptions(): ReadonlyArray { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); + const { connectedEnvironments } = useRemoteConnectionStatus(); return useMemo>(() => { + const runtimeByEnvironmentId = new Map( + connectedEnvironments.map((environment) => [environment.environmentId, environment] as const), + ); const options = Object.values(savedConnectionsById).map((connection) => { const config = serverConfigByEnvironmentId.get(connection.environmentId); + const runtime = runtimeByEnvironmentId.get(connection.environmentId); return { environmentId: connection.environmentId, label: connection.environmentLabel, platform: platformFromOs(config?.environment.platform.os ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, + connectionState: runtime?.connectionState ?? "available", + connectionError: runtime?.connectionError ?? null, + connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, }; }); return Arr.sort(options, environmentOptionOrder); - }, [savedConnectionsById, serverConfigByEnvironmentId]); + }, [connectedEnvironments, savedConnectionsById, serverConfigByEnvironmentId]); } function useSelectedEnvironment(): { @@ -309,8 +326,14 @@ function useSelectedEnvironment(): { const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); const environmentOptions = useEnvironmentOptions(); const selectedEnvironment = - environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? - environmentOptions[0] ?? + environmentOptions.find( + (environment) => + environment.environmentId === selectedEnvironmentId && + canCreateProjectInEnvironment(environment.connectionState), + ) ?? + environmentOptions.find((environment) => + canCreateProjectInEnvironment(environment.connectionState), + ) ?? null; return { @@ -325,9 +348,9 @@ function EmptyEnvironmentState() { return ( - No environments connected + Environment unavailable - Add an environment before adding a project. + Start or reconnect an environment before adding a project. navigation.dispatch(StackActions.replace("ConnectionsNew"))} @@ -407,17 +430,25 @@ export function AddProjectSourceScreen() { return ( - {environmentOptions.length === 0 ? : null} + {selectedEnvironment === null ? : null} {environmentOptions.length > 1 ? ( <> - Connected environments + Environments {environmentOptions.map((environment, index) => ( } selected={environment.environmentId === selectedEnvironment?.environmentId} + disabled={!canCreateProjectInEnvironment(environment.connectionState)} isFirst={index === 0} right={ environment.environmentId === selectedEnvironment?.environmentId ? ( @@ -500,7 +532,7 @@ function useCreateProject(environment: EnvironmentOption | null) { return useCallback( async (workspaceRoot: string) => { - if (!environment) return; + if (!environment || !canCreateProjectInEnvironment(environment.connectionState)) return; const existing = findExistingAddProject({ projects, @@ -552,8 +584,14 @@ function useEnvironmentFromParam( const environmentOptions = useEnvironmentOptions(); const environmentId = stringParam(environmentIdParam) as EnvironmentId | null; return ( - environmentOptions.find((environment) => environment.environmentId === environmentId) ?? - environmentOptions[0] ?? + environmentOptions.find( + (environment) => + environment.environmentId === environmentId && + canCreateProjectInEnvironment(environment.connectionState), + ) ?? + environmentOptions.find((environment) => + canCreateProjectInEnvironment(environment.connectionState), + ) ?? null ); } diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 99985385ea7..2005196f6bb 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -203,13 +203,17 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.goBack() : undefined} - actions={[ - { - accessibilityLabel: "Add project", - icon: "plus", - onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), - }, - ]} + actions={ + catalogState.hasReadyEnvironment + ? [ + { + accessibilityLabel: "Add project", + icon: "plus", + onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), + }, + ] + : [] + } /> ) : ( @@ -229,11 +233,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps ) : null} - navigation.navigate("NewTaskSheet", { screen: "AddProject" })} - separateBackground - /> + {catalogState.hasReadyEnvironment ? ( + navigation.navigate("NewTaskSheet", { screen: "AddProject" })} + separateBackground + /> + ) : null} )} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b4e874017d4..45b5e117600 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,8 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, @@ -163,6 +165,8 @@ interface AddProjectEnvironmentOption { readonly environmentId: EnvironmentId; readonly label: string; readonly isPrimary: boolean; + readonly isConnected: boolean; + readonly status: string; } type AddProjectRemoteProviderKind = Extract< @@ -620,6 +624,8 @@ function OpenCommandPaletteDialog(props: { runtimeLabel: environment.label, }), isPrimary, + isConnected: canCreateProjectInEnvironment(environment.connection.phase), + status: connectionStatusText(environment.connection), }; }); @@ -632,10 +638,14 @@ function OpenCommandPaletteDialog(props: { return options; }, [environments]); - const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; + const defaultAddProjectEnvironmentId = + addProjectEnvironmentOptions.find((option) => option.isConnected)?.environmentId ?? null; const wslAddProjectEnvironmentOption = useMemo( () => addProjectEnvironmentOptions.find((option) => { + if (!option.isConnected) { + return false; + } const environment = environments.find( (candidate) => candidate.environmentId === option.environmentId, ); @@ -1108,6 +1118,19 @@ function OpenCommandPaletteDialog(props: { const startAddProjectSourceSelection = useCallback( (environmentId: EnvironmentId): void => { + const environment = environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (!canCreateProjectInEnvironment(environment?.connection.phase)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Environment unavailable", + description: `${environment?.label ?? "The selected environment"} is not connected.`, + }), + ); + return; + } setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); pushPaletteView({ @@ -1123,6 +1146,7 @@ function OpenCommandPaletteDialog(props: { [ browseEnvironmentId, buildAddProjectSourceGroups, + environments, pushPaletteView, sourceControlDiscovery.data, ], @@ -1134,7 +1158,12 @@ function OpenCommandPaletteDialog(props: { value: `action:add-project:environment:${option.environmentId}`, searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], title: option.label, - description: option.isPrimary ? "This device" : option.environmentId, + description: option.isConnected + ? option.isPrimary + ? "This device" + : option.environmentId + : option.status, + disabled: !option.isConnected, icon: , keepOpen: true, run: async () => { @@ -1155,7 +1184,7 @@ function OpenCommandPaletteDialog(props: { ); const openAddProjectFlow = useCallback(() => { - if (addProjectEnvironmentOptions.length > 1) { + if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) { pushPaletteView({ addonIcon: , groups: addProjectEnvironmentGroups, @@ -1294,6 +1323,7 @@ function OpenCommandPaletteDialog(props: { "environment", ], title: "Add project", + disabled: defaultAddProjectEnvironmentId === null, icon: , keepOpen: true, run: async () => { @@ -1355,6 +1385,19 @@ function OpenCommandPaletteDialog(props: { readonly platform: string; readonly currentProjectCwd: string | null; }) => { + const environment = environments.find( + (candidate) => candidate.environmentId === input.environmentId, + ); + if (!canCreateProjectInEnvironment(environment?.connection.phase)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Environment unavailable", + description: `${environment?.label ?? "The selected environment"} is not connected.`, + }), + ); + return; + } const rawCwd = input.rawCwd; if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) { @@ -1507,6 +1550,16 @@ function OpenCommandPaletteDialog(props: { if (!addProjectCloneFlow) { return; } + if (!canCreateProjectInEnvironment(browseEnvironment?.connection.phase)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Environment unavailable", + description: `${browseEnvironment?.label ?? "The selected environment"} is not connected.`, + }), + ); + return; + } if (addProjectCloneFlow.step === "repository") { const rawRepository = query.trim(); @@ -1711,7 +1764,10 @@ function OpenCommandPaletteDialog(props: { getCommandPaletteInputPlaceholder(paletteMode); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; - const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; + const canSubmitBrowsePath = + isBrowsing && + !relativePathNeedsActiveProject && + canCreateProjectInEnvironment(browseEnvironment?.connection.phase); const willCreateProjectPath = canSubmitBrowsePath && !isBrowsePending && @@ -1738,6 +1794,7 @@ function OpenCommandPaletteDialog(props: { const canSubmitRemoteProjectFlow = addProjectCloneFlow?.step === "repository" && query.trim().length > 0 && + canCreateProjectInEnvironment(browseEnvironment?.connection.phase) && !isRemoteProjectPending; const fileManagerName = getLocalFileManagerName(navigator.platform); const canOpenProjectFromFileManager = @@ -2063,6 +2120,7 @@ function OpenCommandPaletteDialog(props: { )} aria-label={`${submitActionLabel} (${addShortcutLabel})`} disabled={ + !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || relativePathNeedsActiveProject || (isCloneDestinationStep && isRemoteProjectPending) } diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 11b49742460..4cca703c145 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; import { buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, + canCreateProjectInEnvironment, findExistingAddProject, getAddProjectInitialQuery, resolveAddProjectPath, @@ -18,6 +19,15 @@ import { import type { EnvironmentProject } from "../state/models.ts"; describe("add project shared logic", () => { + it("only allows project creation in connected environments", () => { + expect(canCreateProjectInEnvironment("connected")).toBe(true); + expect(canCreateProjectInEnvironment("available")).toBe(false); + expect(canCreateProjectInEnvironment("offline")).toBe(false); + expect(canCreateProjectInEnvironment("connecting")).toBe(false); + expect(canCreateProjectInEnvironment("reconnecting")).toBe(false); + expect(canCreateProjectInEnvironment("error")).toBe(false); + }); + it("resolves initial browse paths from settings", () => { expect(getAddProjectInitialQuery("")).toBe("~/"); expect(getAddProjectInitialQuery("/work")).toBe("/work/"); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 6ae6e18baa2..056f96b21de 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -1,3 +1,4 @@ +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; import type { CommandId, EnvironmentId, @@ -27,6 +28,12 @@ export type AddProjectRemoteProviderKind = Extract< >; export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; +export function canCreateProjectInEnvironment( + connectionPhase: EnvironmentConnectionPhase | null | undefined, +): boolean { + return connectionPhase === "connected"; +} + export type AddProjectRemoteSourceReadiness = Record< AddProjectRemoteSource, { readonly ready: boolean; readonly hint: string | null } From 52260751b40d0cd5783e6c5438ffb73e7aa914f9 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:34:22 +0200 Subject: [PATCH 2/2] fix(mobile): preserve add project environment --- .../projects/AddProjectScreen.logic.test.ts | 41 ++++++++++ .../projects/AddProjectScreen.logic.ts | 26 +++++++ .../features/projects/AddProjectScreen.tsx | 75 ++++++++++--------- 3 files changed, 105 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts create mode 100644 apps/mobile/src/features/projects/AddProjectScreen.logic.ts diff --git a/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts new file mode 100644 index 00000000000..3464bfda260 --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts @@ -0,0 +1,41 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; + +const ENVIRONMENT_A = EnvironmentId.make("environment-a"); +const ENVIRONMENT_B = EnvironmentId.make("environment-b"); + +function environment(environmentId: EnvironmentId, connectionState: EnvironmentConnectionPhase) { + return { environmentId, connectionState }; +} + +describe("resolveAddProjectEnvironment", () => { + it("does not redirect an explicit unavailable environment to another environment", () => { + expect( + resolveAddProjectEnvironment( + [environment(ENVIRONMENT_A, "offline"), environment(ENVIRONMENT_B, "connected")], + ENVIRONMENT_A, + ), + ).toBeNull(); + }); + + it("resolves an explicit connected environment", () => { + expect( + resolveAddProjectEnvironment( + [environment(ENVIRONMENT_A, "connected"), environment(ENVIRONMENT_B, "connected")], + ENVIRONMENT_A, + )?.environmentId, + ).toBe(ENVIRONMENT_A); + }); + + it("defaults to the first connected environment when no environment is requested", () => { + expect( + resolveAddProjectEnvironment( + [environment(ENVIRONMENT_A, "offline"), environment(ENVIRONMENT_B, "connected")], + null, + )?.environmentId, + ).toBe(ENVIRONMENT_B); + }); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.logic.ts b/apps/mobile/src/features/projects/AddProjectScreen.logic.ts new file mode 100644 index 00000000000..b208a1719ab --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectScreen.logic.ts @@ -0,0 +1,26 @@ +import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; + +export function resolveAddProjectEnvironment< + T extends { + readonly environmentId: EnvironmentId; + readonly connectionState: EnvironmentConnectionPhase; + }, +>(environmentOptions: ReadonlyArray, requestedEnvironmentId: EnvironmentId | null): T | null { + if (requestedEnvironmentId !== null) { + return ( + environmentOptions.find( + (environment) => + environment.environmentId === requestedEnvironmentId && + canCreateProjectInEnvironment(environment.connectionState), + ) ?? null + ); + } + + return ( + environmentOptions.find((environment) => + canCreateProjectInEnvironment(environment.connectionState), + ) ?? null + ); +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 98a20fa284d..39e6bda3c44 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -29,7 +29,7 @@ import { import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; @@ -55,6 +55,7 @@ import { useRemoteEnvironmentRuntime, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; +import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -236,10 +237,13 @@ function ProjectPathInput(props: { } function useBrowsePathInput(environment: EnvironmentOption | null) { + const environmentId = environment?.environmentId ?? null; + const environmentBaseDirectory = environment?.baseDirectory ?? null; const [pathInput, commitPathInput] = useState(() => - getAddProjectInitialQuery(environment?.baseDirectory), + getAddProjectInitialQuery(environmentBaseDirectory), ); - const environmentRuntime = useRemoteEnvironmentRuntime(environment?.environmentId ?? null); + const previousEnvironmentIdRef = useRef(environmentId); + const environmentRuntime = useRemoteEnvironmentRuntime(environmentId); const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { reportFailure: false, reportDefect: false, @@ -277,10 +281,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { ); useEffect(() => { - if (environment) { - setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); + if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { + previousEnvironmentIdRef.current = environmentId; + setPathInput(getAddProjectInitialQuery(environmentBaseDirectory)); } - }, [environment, setPathInput]); + }, [environmentBaseDirectory, environmentId, setPathInput]); useEffect( () => () => { @@ -583,17 +588,7 @@ function useEnvironmentFromParam( ): EnvironmentOption | null { const environmentOptions = useEnvironmentOptions(); const environmentId = stringParam(environmentIdParam) as EnvironmentId | null; - return ( - environmentOptions.find( - (environment) => - environment.environmentId === environmentId && - canCreateProjectInEnvironment(environment.connectionState), - ) ?? - environmentOptions.find((environment) => - canCreateProjectInEnvironment(environment.connectionState), - ) ?? - null - ); + return resolveAddProjectEnvironment(environmentOptions, environmentId); } export function AddProjectRepositoryScreen(props: { @@ -657,26 +652,32 @@ export function AddProjectRepositoryScreen(props: { return ( {error ? : null} - void lookupRepository()} - /> - void lookupRepository()} - loading={isSubmitting} - /> + {environment ? ( + <> + void lookupRepository()} + /> + void lookupRepository()} + loading={isSubmitting} + /> + + ) : ( + + )} ); }