diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4cf787d9ce5..224d0ae3fea 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -39,6 +39,7 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; +import { NewTaskEnvironmentRouteScreen } from "./features/threads/NewTaskEnvironmentRouteScreen"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; @@ -218,8 +219,15 @@ const NewTaskSheetStack = createNativeStackNavigator({ }, screens: { NewTask: createNativeStackScreen({ - screen: NewTaskRouteScreen, + screen: NewTaskEnvironmentRouteScreen, linking: "", + options: { + title: "Choose environment", + }, + }), + NewTaskProject: createNativeStackScreen({ + screen: NewTaskRouteScreen, + linking: "projects/:environmentId", options: { title: "Choose project", }, @@ -532,7 +540,7 @@ export const RootStack = createNativeStackNavigator({ NewTaskSheet: createNativeStackScreen({ screen: NewTaskSheetStack, linking: "new", - // The whole new-task flow (choose project → draft → add project) shares + // The whole new-task flow (choose environment → project → draft → add project) shares // draft state via NewTaskFlowProvider. The expo-router era mounted it in // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. layout: ({ children }) => {children}, diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 4f9d994a6a7..09a84f27f30 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -246,12 +246,14 @@ function useEnvironmentOptions(): ReadonlyArray { }, [savedConnectionsById, serverConfigByEnvironmentId]); } -function useSelectedEnvironment(): { +function useSelectedEnvironment(initialEnvironmentId: EnvironmentId | null): { readonly environmentOptions: ReadonlyArray; readonly selectedEnvironment: EnvironmentOption | null; readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; } { - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + initialEnvironmentId, + ); const environmentOptions = useEnvironmentOptions(); const selectedEnvironment = environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? @@ -331,12 +333,17 @@ function SourceControlRow(props: { ); } -export function AddProjectSourceScreen() { +export function AddProjectSourceScreen({ + environmentId, +}: { + readonly environmentId?: string | string[]; +}) { const navigation = useNavigation(); const accentColor = useThemeColor("--color-icon-muted"); const iconColor = useThemeColor("--color-icon"); + const initialEnvironmentId = stringParam(environmentId) as EnvironmentId | null; const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = - useSelectedEnvironment(); + useSelectedEnvironment(initialEnvironmentId); const discoveryState = useEnvironmentQuery( selectedEnvironment === null ? null diff --git a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx index 2dc85606be7..cfaec782b12 100644 --- a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx @@ -1,5 +1,12 @@ import { AddProjectSourceScreen } from "./AddProjectScreen"; +import type { StaticScreenProps } from "@react-navigation/native"; -export function AddProjectSourceRoute() { - return ; +type AddProjectSourceRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectSourceRoute({ + route, +}: StaticScreenProps) { + return ; } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e328bcef00e..d27668c9aa5 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -165,8 +165,14 @@ export function NewTaskDraftScreen(props: { // Let usePreventRemove commit its disabled state before replacing this // route, otherwise the transfer guard can swallow the fallback action. const frame = requestAnimationFrame(() => { + const environmentId = props.initialProjectRef?.environmentId; navigation.dispatch( - StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }), + environmentId + ? StackActions.replace("NewTaskProject", { + environmentId, + incomingShareId: props.incomingShareId, + }) + : StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }), ); }); return () => cancelAnimationFrame(frame); @@ -174,6 +180,7 @@ export function NewTaskDraftScreen(props: { isReturningToProjectPicker, navigation, props.incomingShareId, + props.initialProjectRef?.environmentId, requestedInitialProjectAvailable, ]); useEffect(() => { diff --git a/apps/mobile/src/features/threads/NewTaskEnvironmentRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskEnvironmentRouteScreen.tsx new file mode 100644 index 00000000000..9f2d736e6f1 --- /dev/null +++ b/apps/mobile/src/features/threads/NewTaskEnvironmentRouteScreen.tsx @@ -0,0 +1,239 @@ +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useEffect, useMemo, useRef } from "react"; +import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { useWorkspaceState } from "../../state/workspace"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { buildNewTaskEnvironmentItems, deriveNewTaskPickerEmptyState } from "./newTaskPicker"; + +type NewTaskEnvironmentRouteParams = { + readonly incomingShareId?: string | string[]; +}; + +export function NewTaskEnvironmentRouteScreen({ + route, +}: StaticScreenProps) { + const projects = useProjects(); + const threads = useThreadShells(); + const workspace = useWorkspaceState(); + const navigation = useNavigation(); + const isFocused = useIsFocused(); + const { layout } = useAdaptiveWorkspaceLayout(); + const insets = useSafeAreaInsets(); + const chevronColor = useThemeColor("--color-chevron"); + const accentColor = useThemeColor("--color-icon-muted"); + const { getShare } = useIncomingShare(); + const routeShareId = Array.isArray(route.params?.incomingShareId) + ? route.params.incomingShareId[0] + : route.params?.incomingShareId; + const incomingShare = routeShareId ? getShare(routeShareId) : null; + const environmentItems = useMemo( + () => + buildNewTaskEnvironmentItems({ + environments: workspace.environments, + projects, + threads, + }), + [projects, threads, workspace.environments], + ); + const emptyState = deriveNewTaskPickerEmptyState(workspace.state); + const resumedDestinationKeyRef = useRef(null); + const reservedDestinationProject = incomingShare?.destination + ? (projects.find( + (project) => + project.environmentId === incomingShare.destination?.environmentId && + project.id === incomingShare.destination?.projectId, + ) ?? null) + : null; + const incomingShareSubtitle = incomingShare + ? incomingShare.attachments.length === 0 + ? "Choose where to run what you shared" + : incomingShare.attachments.length === 1 + ? "Choose where to run the image you shared" + : `Choose where to run the ${incomingShare.attachments.length} images you shared` + : null; + const screenTitle = incomingShare ? "Start a task" : "Choose environment"; + + useEffect(() => { + const destination = incomingShare?.destination; + if (!destination) { + resumedDestinationKeyRef.current = null; + return; + } + if (!isFocused) { + resumedDestinationKeyRef.current = null; + return; + } + const destinationKey = `${incomingShare.id}:${destination.environmentId}:${destination.projectId}`; + if ( + resumedDestinationKeyRef.current === destinationKey || + reservedDestinationProject === null + ) { + return; + } + resumedDestinationKeyRef.current = destinationKey; + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: reservedDestinationProject.environmentId, + projectId: reservedDestinationProject.id, + title: reservedDestinationProject.title, + incomingShareId: incomingShare.id, + }, + }); + }, [incomingShare, isFocused, navigation, reservedDestinationProject]); + + const addProject = () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }); + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack() : undefined} + actions={[ + { + accessibilityLabel: "Add project", + icon: "plus", + onPress: addProject, + }, + ]} + /> + + ) : ( + <> + + + {layout.usesSplitView ? ( + navigation.goBack()} + separateBackground + /> + ) : null} + + + + )} + + + {environmentItems.length === 0 ? ( + + {emptyState.loading ? : null} + + {emptyState.title} + + + {emptyState.detail} + + {!workspace.state.hasReadyEnvironment ? ( + navigation.navigate("ConnectionsNew")} + > + + Add environment + + + ) : ( + + + Add new project + + + )} + + ) : ( + + {environmentItems.map((item, index) => { + const isFirst = index === 0; + const isLast = index === environmentItems.length - 1; + + return ( + + navigation.navigate("NewTaskSheet", { + screen: "NewTaskProject", + params: { + environmentId: item.environmentId, + incomingShareId: incomingShare?.id, + }, + }) + } + className={cn( + "bg-card px-4 py-3.5", + !isFirst && "border-t border-border-subtle", + isFirst && "rounded-t-[24px]", + isLast && "rounded-b-[24px]", + )} + > + + + + + + + {item.environmentLabel} + + + {item.projectCount} {item.projectCount === 1 ? "project" : "projects"} + + + + + + ); + })} + + )} + + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 99985385ea7..de6ac821a16 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,7 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useEffect, useMemo, useRef } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -12,82 +12,41 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useProjects, useThreadShells } from "../../state/entities"; -import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; -import { groupProjectsByRepository } from "../../lib/repositoryGroups"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { useEnvironmentShellState } from "../../state/shell"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { + buildNewTaskProjectItems, + deriveNewTaskProjectPickerAction, + deriveNewTaskProjectPickerEmptyState, +} from "./newTaskPicker"; +import * as Option from "effect/Option"; type NewTaskRouteParams = { + readonly environmentId?: string | string[]; readonly incomingShareId?: string | string[]; }; -function deriveProjectEmptyState(catalogState: WorkspaceState): { - readonly title: string; - readonly detail: string; - readonly loading: boolean; -} { - if (catalogState.isLoadingConnections) { - return { - title: "Loading environments", - detail: "Checking saved environments on this device.", - loading: true, - }; - } - - if (!catalogState.hasConnections) { - return { - title: "No environments connected", - detail: "Add an environment before creating a task.", - loading: false, - }; - } - - if ( - (catalogState.connectionState === "available" || - catalogState.connectionState === "offline" || - catalogState.connectionState === "error") && - !catalogState.hasLoadedShellSnapshot - ) { - return { - title: "Environment unavailable", - detail: - catalogState.connectionError ?? - "The saved environment is offline. Check the URL or start the environment, then retry.", - loading: false, - }; - } - - if ( - catalogState.hasConnectingEnvironment && - !catalogState.hasLoadedShellSnapshot && - catalogState.connectionError === null - ) { - return { - title: "Connecting to environment", - detail: "Loading projects from the saved environment.", - loading: true, - }; - } - - return { - title: "No projects found", - detail: "The connected environment did not report any projects.", - loading: false, - }; -} - export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); const threads = useThreadShells(); - const { state: catalogState } = useWorkspaceState(); + const workspace = useWorkspaceState(); const navigation = useNavigation(); const isFocused = useIsFocused(); - const { layout } = useAdaptiveWorkspaceLayout(); const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); const accentColor = useThemeColor("--color-icon-muted"); const { getShare, releaseShareReservation } = useIncomingShare(); + const environmentId = ( + Array.isArray(route.params?.environmentId) + ? route.params.environmentId[0] + : route.params?.environmentId + ) as EnvironmentId | undefined; + const selectedEnvironment = + workspace.environments.find((environment) => environment.environmentId === environmentId) ?? + null; + const environmentLabel = selectedEnvironment?.environmentLabel ?? null; + const environmentShellState = useEnvironmentShellState(environmentId ?? null); const routeShareId = Array.isArray(route.params?.incomingShareId) ? route.params.incomingShareId[0] : route.params?.incomingShareId; @@ -100,34 +59,31 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps groupProjectsByRepository({ projects, threads }), - [projects, threads], + const items = useMemo( + () => + environmentId + ? buildNewTaskProjectItems({ + environmentId, + projects, + threads, + }) + : [], + [environmentId, projects, threads], ); - const items = useMemo(() => { - const nextItems: Array<{ - readonly environmentId: EnvironmentId; - readonly id: ProjectId; - readonly key: string; - readonly title: string; - readonly workspaceRoot: string; - }> = []; - for (const group of repositoryGroups) { - const project = group.projects[0]?.project; - if (!project) { - continue; - } - nextItems.push({ - environmentId: project.environmentId, - id: project.id, - key: group.key, - title: project.title, - workspaceRoot: project.workspaceRoot, - }); - } - return nextItems; - }, [repositoryGroups]); - const projectEmptyState = deriveProjectEmptyState(catalogState); + const hasEnvironmentShellSnapshot = Option.isSome(environmentShellState.snapshot); + const projectEmptyState = deriveNewTaskProjectPickerEmptyState({ + environment: selectedEnvironment, + networkOffline: workspace.state.networkStatus === "offline", + shellStatus: environmentShellState.status, + shellError: Option.getOrNull(environmentShellState.error), + hasShellSnapshot: hasEnvironmentShellSnapshot, + }); + const emptyStateAction = deriveNewTaskProjectPickerAction({ + hasSelectedEnvironment: selectedEnvironment !== null, + canAddProject: + selectedEnvironment?.connectionState === "connected" && hasEnvironmentShellSnapshot, + loading: projectEmptyState.loading, + }); const resumedDestinationKeyRef = useRef(null); const reservedDestinationProject = incomingShare?.destination ? (projects.find( @@ -201,13 +157,17 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.goBack() : undefined} + subtitle={incomingShareSubtitle ?? environmentLabel} + onBack={() => navigation.goBack()} actions={[ { accessibilityLabel: "Add project", icon: "plus", - onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), + onPress: () => + navigation.navigate("NewTaskSheet", { + screen: "AddProject", + params: { environmentId }, + }), }, ]} /> @@ -217,21 +177,18 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - {layout.usesSplitView ? ( - navigation.goBack()} - separateBackground - /> - ) : null} navigation.navigate("NewTaskSheet", { screen: "AddProject" })} + onPress={() => + navigation.navigate("NewTaskSheet", { + screen: "AddProject", + params: { environmentId }, + }) + } separateBackground /> @@ -258,7 +215,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {projectEmptyState.detail} - {!catalogState.hasReadyEnvironment ? ( + {emptyStateAction === "add-environment" ? ( navigation.navigate("ConnectionsNew")} @@ -267,16 +224,21 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - ) : ( + ) : emptyStateAction === "add-project" ? ( navigation.navigate("NewTaskSheet", { screen: "AddProject" })} + onPress={() => + navigation.navigate("NewTaskSheet", { + screen: "AddProject", + params: { environmentId }, + }) + } > Add new project - )} + ) : null} ) : ( @@ -287,6 +249,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps void selectProject(item)} className={cn( diff --git a/apps/mobile/src/features/threads/newTaskPicker.test.ts b/apps/mobile/src/features/threads/newTaskPicker.test.ts new file mode 100644 index 00000000000..22cc601e4ec --- /dev/null +++ b/apps/mobile/src/features/threads/newTaskPicker.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +import { + buildNewTaskEnvironmentItems, + buildNewTaskProjectItems, + deriveNewTaskProjectPickerAction, + deriveNewTaskProjectPickerEmptyState, +} from "./newTaskPicker"; + +function makeProject( + input: Partial & Pick, +): EnvironmentProject { + return { + workspaceRoot: `/workspaces/${input.id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + ...input, + }; +} + +function makeThread( + input: Partial & + Pick, +): EnvironmentThreadShell { + return { + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + settledOverride: null, + settledAt: null, + ...input, + }; +} + +describe("new task picker", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + provider: "github", + owner: "t3tools", + name: "t3code", + displayName: "T3 Code", + }; + const localProject = makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-local"), + title: "T3 Code local", + repositoryIdentity, + }); + const remoteProject = makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-remote"), + title: "T3 Code remote", + repositoryIdentity, + }); + const localThread = makeThread({ + environmentId: localEnvironmentId, + id: ThreadId.make("thread-local"), + projectId: localProject.id, + title: "Recent local work", + updatedAt: "2026-04-03T00:00:00.000Z", + }); + + it("keeps project choices scoped to the selected environment", () => { + const items = buildNewTaskProjectItems({ + environmentId: remoteEnvironmentId, + projects: [localProject, remoteProject], + threads: [localThread], + }); + + expect(items).toEqual([ + { + environmentId: remoteEnvironmentId, + id: remoteProject.id, + key: repositoryIdentity.canonicalKey, + title: remoteProject.title, + workspaceRoot: remoteProject.workspaceRoot, + }, + ]); + }); + + it("lists only environments with projects and counts logical projects per environment", () => { + const scratchProject = makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-scratch"), + title: "Scratch", + }); + + const items = buildNewTaskEnvironmentItems({ + environments: [ + { environmentId: localEnvironmentId, environmentLabel: "Laptop" }, + { + environmentId: remoteEnvironmentId, + environmentLabel: "Build server", + }, + { + environmentId: EnvironmentId.make("environment-empty"), + environmentLabel: "Empty server", + }, + ], + projects: [localProject, remoteProject, scratchProject], + threads: [localThread], + }); + + expect(items).toEqual([ + { + environmentId: localEnvironmentId, + environmentLabel: "Laptop", + projectCount: 1, + }, + { + environmentId: remoteEnvironmentId, + environmentLabel: "Build server", + projectCount: 2, + }, + ]); + }); + + it("derives an empty state from the selected environment instead of another ready environment", () => { + expect( + deriveNewTaskProjectPickerEmptyState({ + environment: { + connectionState: "offline", + connectionError: "Build server is stopped.", + }, + networkOffline: false, + shellStatus: "empty", + shellError: null, + hasShellSnapshot: false, + }), + ).toEqual({ + title: "Environment unavailable", + detail: "Build server is stopped.", + loading: false, + }); + + expect( + deriveNewTaskProjectPickerEmptyState({ + environment: { + connectionState: "connected", + connectionError: null, + }, + networkOffline: false, + shellStatus: "synchronizing", + shellError: null, + hasShellSnapshot: false, + }), + ).toEqual({ + title: "Loading projects", + detail: "Loading projects from the selected environment.", + loading: true, + }); + }); + + it("reports a shell failure on a connected environment instead of loading forever", () => { + expect( + deriveNewTaskProjectPickerEmptyState({ + environment: { + connectionState: "connected", + connectionError: null, + }, + networkOffline: false, + shellStatus: "empty", + shellError: "Could not synchronize environment data.", + hasShellSnapshot: false, + }), + ).toEqual({ + title: "Could not load projects", + detail: "Could not synchronize environment data.", + loading: false, + }); + }); + + it("keeps reporting an empty catalog when a shell error arrives with a snapshot", () => { + expect( + deriveNewTaskProjectPickerEmptyState({ + environment: { + connectionState: "connected", + connectionError: null, + }, + networkOffline: false, + shellStatus: "cached", + shellError: "Could not synchronize environment data.", + hasShellSnapshot: true, + }), + ).toEqual({ + title: "No projects found", + detail: "The selected environment did not report any projects.", + loading: false, + }); + }); + + it("offers adding an environment only when no environment is selected", () => { + expect( + deriveNewTaskProjectPickerAction({ + hasSelectedEnvironment: false, + canAddProject: false, + loading: false, + }), + ).toBe("add-environment"); + }); + + it("does not offer adding an environment for a selected environment that is unavailable", () => { + expect( + deriveNewTaskProjectPickerAction({ + hasSelectedEnvironment: true, + canAddProject: false, + loading: false, + }), + ).toBe("none"); + }); + + it("hides the empty state action while the selected environment is still loading", () => { + expect( + deriveNewTaskProjectPickerAction({ + hasSelectedEnvironment: true, + canAddProject: false, + loading: true, + }), + ).toBe("none"); + }); + + it("offers adding a project once the selected environment is ready", () => { + expect( + deriveNewTaskProjectPickerAction({ + hasSelectedEnvironment: true, + canAddProject: true, + loading: false, + }), + ).toBe("add-project"); + }); +}); diff --git a/apps/mobile/src/features/threads/newTaskPicker.ts b/apps/mobile/src/features/threads/newTaskPicker.ts new file mode 100644 index 00000000000..4158c987cd2 --- /dev/null +++ b/apps/mobile/src/features/threads/newTaskPicker.ts @@ -0,0 +1,206 @@ +import type { + EnvironmentProject, + EnvironmentShellStatus, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +import { groupProjectsByRepository } from "../../lib/repositoryGroups"; +import type { WorkspaceState } from "../../state/workspaceModel"; + +export interface NewTaskPickerEnvironment { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +} + +export interface NewTaskEnvironmentItem extends NewTaskPickerEnvironment { + readonly projectCount: number; +} + +export interface NewTaskProjectItem { + readonly environmentId: EnvironmentId; + readonly id: ProjectId; + readonly key: string; + readonly title: string; + readonly workspaceRoot: string; +} + +export function deriveNewTaskProjectPickerEmptyState(input: { + readonly environment: { + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + } | null; + readonly networkOffline: boolean; + readonly shellStatus: EnvironmentShellStatus; + readonly shellError: string | null; + readonly hasShellSnapshot: boolean; +}): { + readonly title: string; + readonly detail: string; + readonly loading: boolean; +} { + const connectionUnavailable = + input.environment === null || + input.networkOffline || + input.environment.connectionState === "available" || + input.environment.connectionState === "offline" || + input.environment.connectionState === "error"; + + if (connectionUnavailable && !input.hasShellSnapshot) { + return { + title: "Environment unavailable", + detail: + input.environment?.connectionError ?? + input.shellError ?? + "The saved environment is offline. Check the URL or start the environment, then retry.", + loading: false, + }; + } + + if (input.shellError !== null && !input.hasShellSnapshot) { + // The connection itself can stay healthy while the shell subscription + // fails. Without this branch the picker spins on "Loading projects" and + // never reports why no project ever arrives. + return { + title: "Could not load projects", + detail: input.shellError, + loading: false, + }; + } + + if (!input.hasShellSnapshot) { + return { + title: + input.shellStatus === "synchronizing" ? "Loading projects" : "Connecting to environment", + detail: "Loading projects from the selected environment.", + loading: true, + }; + } + + return { + title: "No projects found", + detail: "The selected environment did not report any projects.", + loading: false, + }; +} + +export type NewTaskProjectPickerAction = "none" | "add-environment" | "add-project"; + +export function deriveNewTaskProjectPickerAction(input: { + readonly hasSelectedEnvironment: boolean; + readonly canAddProject: boolean; + readonly loading: boolean; +}): NewTaskProjectPickerAction { + if (input.canAddProject) { + return "add-project"; + } + if (input.loading) { + return "none"; + } + // Adding another connection is only useful when no environment was chosen. + // Once one is selected the offer contradicts the screen the user is on, and + // adding a project needs that environment to be reachable first. + return input.hasSelectedEnvironment ? "none" : "add-environment"; +} + +export function deriveNewTaskPickerEmptyState(catalogState: WorkspaceState): { + readonly title: string; + readonly detail: string; + readonly loading: boolean; +} { + if (catalogState.isLoadingConnections) { + return { + title: "Loading environments", + detail: "Checking saved environments on this device.", + loading: true, + }; + } + + if (!catalogState.hasConnections) { + return { + title: "No environments connected", + detail: "Add an environment before creating a task.", + loading: false, + }; + } + + if ( + (catalogState.connectionState === "available" || + catalogState.connectionState === "offline" || + catalogState.connectionState === "error") && + !catalogState.hasLoadedShellSnapshot + ) { + return { + title: "Environment unavailable", + detail: + catalogState.connectionError ?? + "The saved environment is offline. Check the URL or start the environment, then retry.", + loading: false, + }; + } + + if ( + catalogState.hasConnectingEnvironment && + !catalogState.hasLoadedShellSnapshot && + catalogState.connectionError === null + ) { + return { + title: "Connecting to environment", + detail: "Loading projects from the saved environment.", + loading: true, + }; + } + + return { + title: "No projects found", + detail: "The connected environment did not report any projects.", + loading: false, + }; +} + +export function buildNewTaskProjectItems(input: { + readonly environmentId: EnvironmentId; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}): ReadonlyArray { + const environmentProjects = input.projects.filter( + (project) => project.environmentId === input.environmentId, + ); + const environmentThreads = input.threads.filter( + (thread) => thread.environmentId === input.environmentId, + ); + + return groupProjectsByRepository({ + projects: environmentProjects, + threads: environmentThreads, + }).flatMap((group): ReadonlyArray => { + const project = group.projects[0]?.project; + return project + ? [ + { + environmentId: project.environmentId, + id: project.id, + key: group.key, + title: project.title, + workspaceRoot: project.workspaceRoot, + }, + ] + : []; + }); +} + +export function buildNewTaskEnvironmentItems(input: { + readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}): ReadonlyArray { + return input.environments.flatMap((environment): ReadonlyArray => { + const projectCount = buildNewTaskProjectItems({ + environmentId: environment.environmentId, + projects: input.projects, + threads: input.threads, + }).length; + return projectCount > 0 ? [{ ...environment, projectCount }] : []; + }); +} diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts index e879dd25e29..37cecddd80f 100644 --- a/apps/mobile/src/state/shell.ts +++ b/apps/mobile/src/state/shell.ts @@ -3,7 +3,12 @@ import { createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, + type EnvironmentShellState, } from "@t3tools/client-runtime/state/shell"; +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -15,3 +20,19 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, shellStateValueAtom: environmentShell.stateValueAtom, }); + +const EMPTY_ENVIRONMENT_SHELL_STATE_ATOM = Atom.make({ + snapshot: Option.none(), + status: "empty", + error: Option.none(), +}).pipe(Atom.withLabel("mobile-environment-shell:empty")); + +export function useEnvironmentShellState( + environmentId: EnvironmentId | null, +): EnvironmentShellState { + return useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_SHELL_STATE_ATOM + : environmentShell.stateValueAtom(environmentId), + ); +}