authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 12:55:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 12:55:17-07:00
log93e04160a0c0dece8a258384d2118a660415a53d
tree719382043206ef92d1104be6923a444e880d0c42
parent88b9226d229a7d51283122a8b8b8be8305822a9e
signature Signed by PGP key B5690EEEBB952194

feat(mobile): search projects when starting a task (#12496)


3 files changed, 116 insertions(+), 56 deletions(-)

apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+70-56
......@@ -1,5 +1,5 @@
11import { MaterialListRow } from "../../components/MaterialListRow";
2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { ScreenHeader } from "../../components/ScreenHeader";
33import {
44 StackActions,
55 useIsFocused,
......@@ -8,11 +8,10 @@ import {
88} from "@react-navigation/native";
99import { SymbolView } from "../../components/AppSymbol";
1010import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
11import { useEffect, useRef } from "react";
11import { useEffect, useRef, useState } from "react";
1212import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
1313import { useSafeAreaInsets } from "react-native-safe-area-context";
1414import { cn } from "../../lib/cn";
15import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1615import { MaterialScreenContent } from "../../components/MaterialScreenContent";
1716import { MaterialButton } from "../../components/MaterialButton";
1817import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
......@@ -24,7 +23,7 @@ import { useWorkspaceState } from "../../state/workspace";
2423import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
2524import { useIncomingShare } from "../sharing/IncomingShareProvider";
2625import { useNewTaskFlow } from "./new-task-flow-provider";
27import { getProjectScopeSelectionTarget } from "./new-task-project-selection";
26import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection";
2827
2928type NewTaskRouteParams = {
3029 readonly incomingShareId?: string | string[];
......@@ -85,13 +84,58 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {
8584 };
8685}
8786
87function NewTaskHeader(props: {
88 readonly title: string;
89 readonly subtitle: string | null;
90 readonly canAddProject: boolean;
91 readonly searchText: string;
92 readonly onSearchTextChange: (text: string) => void;
93}) {
94 const navigation = useNavigation();
95 const { layout } = useAdaptiveWorkspaceLayout();
96 return (
97 <ScreenHeader
98 title={props.title}
99 subtitle={props.subtitle ?? undefined}
100 sidebar={false}
101 hideBottomBorder
102 onBack={() => navigation.goBack()}
103 actions={[
104 ...(Platform.OS === "ios" && layout.usesSplitView
105 ? [
106 {
107 accessibilityLabel: "Close new task",
108 icon: "xmark" as const,
109 onPress: () => navigation.goBack(),
110 },
111 ]
112 : []),
113 ...(props.canAddProject
114 ? [
115 {
116 accessibilityLabel: "Add project",
117 icon: "plus" as const,
118 onPress: () => navigation.dispatch(StackActions.push("AddProject")),
119 },
120 ]
121 : []),
122 ]}
123 search={{
124 value: props.searchText,
125 onChangeText: props.onSearchTextChange,
126 placeholder: "Search projects",
127 }}
128 />
129 );
130}
131
88132export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {
89133 const projects = useProjects();
134 const [searchText, setSearchText] = useState("");
90135 const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow();
91136 const { state: catalogState } = useWorkspaceState();
92137 const navigation = useNavigation();
93138 const isFocused = useIsFocused();
94 const { layout } = useAdaptiveWorkspaceLayout();
95139 const insets = useSafeAreaInsets();
96140 const { getShare, releaseShareReservation } = useIncomingShare();
97141 const routeShareId = Array.isArray(route.params?.incomingShareId)
......@@ -107,6 +151,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
107151 : null;
108152 const screenTitle = incomingShare ? "Start a task" : "Choose project";
109153 const projectEmptyState = deriveProjectEmptyState(catalogState);
154 const visibleScopes = filterProjectScopes(projectScopes, searchText);
110155 const resumedDestinationKeyRef = useRef<string | null>(null);
111156 const reservedDestinationProject = incomingShare?.destination
112157 ? (projects.find(
......@@ -180,67 +225,27 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
180225
181226 return (
182227 <View collapsable={false} className="flex-1 bg-sheet">
183 {Platform.OS === "android" ? (
184 <>
185 {/* Android renders its own in-screen header instead of the native bar. */}
186 <NativeStackScreenOptions options={{ headerShown: false }} />
187 <AndroidScreenHeader
188 title={screenTitle}
189 hideBottomBorder
190 subtitle={incomingShareSubtitle}
191 onBack={() => navigation.goBack()}
192 actions={
193 catalogState.hasReadyEnvironment
194 ? [
195 {
196 accessibilityLabel: "Add project",
197 icon: "plus",
198 onPress: () => navigation.dispatch(StackActions.push("AddProject")),
199 },
200 ]
201 : []
202 }
203 />
204 </>
205 ) : (
206 <>
207 <NativeStackScreenOptions
208 options={{
209 title: screenTitle,
210 unstable_headerSubtitle: incomingShareSubtitle ?? undefined,
211 }}
212 />
213 <NativeHeaderToolbar placement="right">
214 {layout.usesSplitView ? (
215 <NativeHeaderToolbar.Button
216 accessibilityLabel="Close new task"
217 icon="xmark"
218 onPress={() => navigation.goBack()}
219 separateBackground
220 />
221 ) : null}
222 {catalogState.hasReadyEnvironment ? (
223 <NativeHeaderToolbar.Button
224 icon="plus"
225 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
226 separateBackground
227 />
228 ) : null}
229 </NativeHeaderToolbar>
230 </>
231 )}
228 <NewTaskHeader
229 title={screenTitle}
230 subtitle={incomingShareSubtitle}
231 canAddProject={catalogState.hasReadyEnvironment}
232 searchText={searchText}
233 onSearchTextChange={setSearchText}
234 />
232235
233236 <MaterialScreenContent>
234237 <ScrollView
235238 contentInsetAdjustmentBehavior="automatic"
236239 showsVerticalScrollIndicator={false}
240 keyboardShouldPersistTaps="handled"
241 keyboardDismissMode="on-drag"
237242 className="flex-1"
238243 contentContainerStyle={{
239244 gap: Platform.OS === "android" ? 8 : 12,
240245 paddingBottom: Math.max(insets.bottom, 18) + 18,
241246 paddingHorizontal: Platform.OS === "android" ? 16 : 20,
242247 paddingTop: Platform.OS === "android" ? 16 : 8,
243 ...(Platform.OS === "android" && projectScopes.length === 0
248 ...(Platform.OS === "android" && visibleScopes.length === 0
244249 ? { flexGrow: 1, justifyContent: "center" as const }
245250 : {}),
246251 }}
......@@ -292,6 +297,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
292297 </Pressable>
293298 )}
294299 </View>
300 ) : visibleScopes.length === 0 ? (
301 <View className="items-center gap-2 px-6 py-8">
302 <Text className="text-center text-lg font-t3-bold text-foreground">
303 No matching projects
304 </Text>
305 <Text className="text-center text-sm leading-normal text-foreground-muted">
306 Try a different project name or workspace path.
307 </Text>
308 </View>
295309 ) : (
296310 <View
297311 collapsable={false}
......@@ -301,7 +315,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
301315 : "overflow-hidden rounded-[24px] bg-card"
302316 }
303317 >
304 {projectScopes.map((scope, scopeIndex) => {
318 {visibleScopes.map((scope, scopeIndex) => {
305319 const hasMultipleProjects = scope.projects.length > 1;
306320 const selectionTarget = getProjectScopeSelectionTarget(
307321 scope,
apps/mobile/src/features/threads/new-task-project-selection.test.ts+29
......@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
44import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
55import type { HomeProjectScope } from "../home/homeThreadList";
66import {
7 filterProjectScopes,
78 getProjectScopeSelectionTarget,
89 resolveDraftProjectSelection,
910 resolveEnvironmentProjectMatch,
......@@ -152,3 +153,31 @@ describe("resolveDraftProjectSelection", () => {
152153 });
153154 });
154155});
156
157describe("filterProjectScopes", () => {
158 const mac = makeProject("code", "mac", { title: "Desktop checkout" });
159 const server = makeProject("remote-code", "server", { workspaceRoot: "/srv/remote-workspace" });
160 const code = makeScope([mac, server]);
161 const docs = { ...makeScope([makeProject("docs")]), key: "docs", title: "Documentation" };
162 const scopes = [code, docs];
163
164 it("keeps all projects for an empty or whitespace-only query", () => {
165 expect(filterProjectScopes(scopes, "")).toBe(scopes);
166 expect(filterProjectScopes(scopes, " ")).toBe(scopes);
167 });
168
169 it("matches logical names and workspace names or paths without case sensitivity", () => {
170 expect(filterProjectScopes(scopes, " T3 CODE ")).toEqual([code]);
171 expect(filterProjectScopes(scopes, "DESKTOP")).toEqual([code]);
172 expect(filterProjectScopes(scopes, "REMOTE-WORKSPACE")).toEqual([code]);
173 expect(filterProjectScopes(scopes, "documentation")).toEqual([docs]);
174 expect(filterProjectScopes(scopes, "missing-project")).toEqual([]);
175 });
176
177 it("preserves the whole logical project and preferred environment when a workspace matches", () => {
178 const matches = filterProjectScopes(scopes, "REMOTE-WORKSPACE");
179 expect(matches[0]).toBe(code);
180 expect(getProjectScopeSelectionTarget(matches[0]!, EnvironmentId.make("mac"))).toBe(mac);
181 expect(code.projects).toEqual([mac, server]);
182 });
183});
apps/mobile/src/features/threads/new-task-project-selection.ts+17
......@@ -19,6 +19,23 @@ export function getProjectScopeSelectionTarget(
1919 );
2020}
2121
22export function filterProjectScopes(
23 scopes: ReadonlyArray<HomeProjectScope>,
24 searchText: string,
25): ReadonlyArray<HomeProjectScope> {
26 const query = searchText.trim().toLowerCase();
27 if (!query) return scopes;
28 return scopes.filter(
29 (scope) =>
30 scope.title.toLowerCase().includes(query) ||
31 scope.projects.some(
32 (project) =>
33 project.title.toLowerCase().includes(query) ||
34 project.workspaceRoot.toLowerCase().includes(query),
35 ),
36 );
37}
38
2239function getOnlySelectableProject(
2340 projectScopes: ReadonlyArray<HomeProjectScope>,
2441): EnvironmentProject | null {