Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -218,8 +219,15 @@ const NewTaskSheetStack = createNativeStackNavigator({
},
screens: {
NewTask: createNativeStackScreen({
screen: NewTaskRouteScreen,
screen: NewTaskEnvironmentRouteScreen,
linking: "",
options: {
title: "Choose environment",
},
}),
Comment thread
cursor[bot] marked this conversation as resolved.
NewTaskProject: createNativeStackScreen({
screen: NewTaskRouteScreen,
linking: "projects/:environmentId",
options: {
title: "Choose project",
},
Expand Down Expand Up @@ -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 }) => <NewTaskFlowProvider>{children}</NewTaskFlowProvider>,
Expand Down
15 changes: 11 additions & 4 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,14 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
}, [savedConnectionsById, serverConfigByEnvironmentId]);
}

function useSelectedEnvironment(): {
function useSelectedEnvironment(initialEnvironmentId: EnvironmentId | null): {
readonly environmentOptions: ReadonlyArray<EnvironmentOption>;
readonly selectedEnvironment: EnvironmentOption | null;
readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;
} {
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(
initialEnvironmentId,
);
const environmentOptions = useEnvironmentOptions();
const selectedEnvironment =
environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ??
Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/features/projects/AddProjectSourceRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { AddProjectSourceScreen } from "./AddProjectScreen";
import type { StaticScreenProps } from "@react-navigation/native";

export function AddProjectSourceRoute() {
return <AddProjectSourceScreen />;
type AddProjectSourceRouteParams = {
readonly environmentId?: string | string[];
};

export function AddProjectSourceRoute({
route,
}: StaticScreenProps<AddProjectSourceRouteParams | undefined>) {
return <AddProjectSourceScreen environmentId={route.params?.environmentId} />;
}
9 changes: 8 additions & 1 deletion apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,22 @@ 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 }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Draft fallback duplicates project screen

Medium Severity

When the draft screen falls back to NewTaskProject, StackActions.replace can leave a duplicate NewTaskProject route on the stack if one already existed. This causes back navigation to return to a stale project picker instead of the environment stage.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0bad292. Configure here.

);
});
return () => cancelAnimationFrame(frame);
}, [
isReturningToProjectPicker,
navigation,
props.incomingShareId,
props.initialProjectRef?.environmentId,
requestedInitialProjectAvailable,
]);
useEffect(() => {
Expand Down
239 changes: 239 additions & 0 deletions apps/mobile/src/features/threads/NewTaskEnvironmentRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -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<NewTaskEnvironmentRouteParams | undefined>) {
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<string | null>(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 (
<View collapsable={false} className="flex-1 bg-sheet">
{Platform.OS === "android" ? (
<>
<NativeStackScreenOptions options={{ headerShown: false }} />
<AndroidScreenHeader
title={screenTitle}
subtitle={incomingShareSubtitle}
onBack={layout.usesSplitView ? () => navigation.goBack() : undefined}
actions={[
{
accessibilityLabel: "Add project",
icon: "plus",
onPress: addProject,
},
]}
/>
</>
) : (
<>
<NativeStackScreenOptions
options={{
title: screenTitle,
unstable_headerSubtitle: incomingShareSubtitle ?? undefined,
}}
/>
<NativeHeaderToolbar placement="right">
{layout.usesSplitView ? (
<NativeHeaderToolbar.Button
accessibilityLabel="Close new task"
icon="xmark"
onPress={() => navigation.goBack()}
separateBackground
/>
) : null}
<NativeHeaderToolbar.Button icon="plus" onPress={addProject} separateBackground />
</NativeHeaderToolbar>
</>
)}

<ScrollView
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
className="flex-1"
contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
contentContainerStyle={{
gap: 12,
paddingHorizontal: 20,
paddingTop: 8,
}}
>
{environmentItems.length === 0 ? (
<View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
{emptyState.loading ? <ActivityIndicator color={accentColor} /> : null}
<Text className="text-center text-lg font-t3-bold text-foreground">
{emptyState.title}
</Text>
<Text className="text-center text-sm leading-normal text-foreground-muted">
{emptyState.detail}
</Text>
{!workspace.state.hasReadyEnvironment ? (
<Pressable
className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
onPress={() => navigation.navigate("ConnectionsNew")}
>
<Text className="text-sm font-t3-bold text-primary-foreground">
Add environment
</Text>
</Pressable>
) : (
<Pressable
className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
onPress={addProject}
>
<Text className="text-sm font-t3-bold text-primary-foreground">
Add new project
</Text>
</Pressable>
)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading empty state still shows CTAs

Medium Severity

The new environment empty state shows "Add environment" or "Add new project" buttons while emptyState.loading is true. This contradicts the loading messages and could prompt users to add items prematurely.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0bad292. Configure here.

</View>
) : (
<View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
{environmentItems.map((item, index) => {
const isFirst = index === 0;
const isLast = index === environmentItems.length - 1;

return (
<Pressable
key={item.environmentId}
accessibilityLabel={`${item.environmentLabel}, ${item.projectCount} ${
item.projectCount === 1 ? "project" : "projects"
}`}
accessibilityRole="button"
disabled={reservedDestinationProject !== null}
onPress={() =>
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]",
)}
>
<View className="flex-row items-center justify-between gap-3">
<View className="h-7 w-7 items-center justify-center">
<SymbolView
name="desktopcomputer"
size={20}
tintColor={accentColor}
type="monochrome"
/>
</View>
<View className="flex-1">
<Text className="text-base leading-snug font-t3-bold">
{item.environmentLabel}
</Text>
<Text className="text-sm text-foreground-muted">
{item.projectCount} {item.projectCount === 1 ? "project" : "projects"}
</Text>
</View>
<SymbolView
name="chevron.right"
size={14}
tintColor={chevronColor}
type="monochrome"
/>
</View>
</Pressable>
);
})}
</View>
)}
</ScrollView>
</View>
);
}
Loading
Loading