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 @@...@@ -1,5 +1,5 @@
1import { MaterialListRow } from "../../components/MaterialListRow";1import { MaterialListRow } from "../../components/MaterialListRow";
2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";2import { ScreenHeader } from "../../components/ScreenHeader";
3import {3import {
4 StackActions,4 StackActions,
5 useIsFocused,5 useIsFocused,
...@@ -8,11 +8,10 @@ import {...@@ -8,11 +8,10 @@ import {
8} from "@react-navigation/native";8} from "@react-navigation/native";
9import { SymbolView } from "../../components/AppSymbol";9import { SymbolView } from "../../components/AppSymbol";
10import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";10import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
11import { useEffect, useRef } from "react";11import { useEffect, useRef, useState } from "react";
12import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";12import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
13import { useSafeAreaInsets } from "react-native-safe-area-context";13import { useSafeAreaInsets } from "react-native-safe-area-context";
14import { cn } from "../../lib/cn";14import { cn } from "../../lib/cn";
15import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
16import { MaterialScreenContent } from "../../components/MaterialScreenContent";15import { MaterialScreenContent } from "../../components/MaterialScreenContent";
17import { MaterialButton } from "../../components/MaterialButton";16import { MaterialButton } from "../../components/MaterialButton";
18import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";17import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
...@@ -24,7 +23,7 @@ import { useWorkspaceState } from "../../state/workspace";...@@ -24,7 +23,7 @@ import { useWorkspaceState } from "../../state/workspace";
24import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";23import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
25import { useIncomingShare } from "../sharing/IncomingShareProvider";24import { useIncomingShare } from "../sharing/IncomingShareProvider";
26import { useNewTaskFlow } from "./new-task-flow-provider";25import { useNewTaskFlow } from "./new-task-flow-provider";
27import { getProjectScopeSelectionTarget } from "./new-task-project-selection";26import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection";
2827
29type NewTaskRouteParams = {28type NewTaskRouteParams = {
30 readonly incomingShareId?: string | string[];29 readonly incomingShareId?: string | string[];
...@@ -85,13 +84,58 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {...@@ -85,13 +84,58 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {
85 };84 };
86}85}
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
88export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {132export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {
89 const projects = useProjects();133 const projects = useProjects();
134 const [searchText, setSearchText] = useState("");
90 const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow();135 const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow();
91 const { state: catalogState } = useWorkspaceState();136 const { state: catalogState } = useWorkspaceState();
92 const navigation = useNavigation();137 const navigation = useNavigation();
93 const isFocused = useIsFocused();138 const isFocused = useIsFocused();
94 const { layout } = useAdaptiveWorkspaceLayout();
95 const insets = useSafeAreaInsets();139 const insets = useSafeAreaInsets();
96 const { getShare, releaseShareReservation } = useIncomingShare();140 const { getShare, releaseShareReservation } = useIncomingShare();
97 const routeShareId = Array.isArray(route.params?.incomingShareId)141 const routeShareId = Array.isArray(route.params?.incomingShareId)
...@@ -107,6 +151,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -107,6 +151,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
107 : null;151 : null;
108 const screenTitle = incomingShare ? "Start a task" : "Choose project";152 const screenTitle = incomingShare ? "Start a task" : "Choose project";
109 const projectEmptyState = deriveProjectEmptyState(catalogState);153 const projectEmptyState = deriveProjectEmptyState(catalogState);
154 const visibleScopes = filterProjectScopes(projectScopes, searchText);
110 const resumedDestinationKeyRef = useRef<string | null>(null);155 const resumedDestinationKeyRef = useRef<string | null>(null);
111 const reservedDestinationProject = incomingShare?.destination156 const reservedDestinationProject = incomingShare?.destination
112 ? (projects.find(157 ? (projects.find(
...@@ -180,67 +225,27 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -180,67 +225,27 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
180225
181 return (226 return (
182 <View collapsable={false} className="flex-1 bg-sheet">227 <View collapsable={false} className="flex-1 bg-sheet">
183 {Platform.OS === "android" ? (228 <NewTaskHeader
184 <>229 title={screenTitle}
185 {/* Android renders its own in-screen header instead of the native bar. */}230 subtitle={incomingShareSubtitle}
186 <NativeStackScreenOptions options={{ headerShown: false }} />231 canAddProject={catalogState.hasReadyEnvironment}
187 <AndroidScreenHeader232 searchText={searchText}
188 title={screenTitle}233 onSearchTextChange={setSearchText}
189 hideBottomBorder234 />
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 )}
232235
233 <MaterialScreenContent>236 <MaterialScreenContent>
234 <ScrollView237 <ScrollView
235 contentInsetAdjustmentBehavior="automatic"238 contentInsetAdjustmentBehavior="automatic"
236 showsVerticalScrollIndicator={false}239 showsVerticalScrollIndicator={false}
240 keyboardShouldPersistTaps="handled"
241 keyboardDismissMode="on-drag"
237 className="flex-1"242 className="flex-1"
238 contentContainerStyle={{243 contentContainerStyle={{
239 gap: Platform.OS === "android" ? 8 : 12,244 gap: Platform.OS === "android" ? 8 : 12,
240 paddingBottom: Math.max(insets.bottom, 18) + 18,245 paddingBottom: Math.max(insets.bottom, 18) + 18,
241 paddingHorizontal: Platform.OS === "android" ? 16 : 20,246 paddingHorizontal: Platform.OS === "android" ? 16 : 20,
242 paddingTop: Platform.OS === "android" ? 16 : 8,247 paddingTop: Platform.OS === "android" ? 16 : 8,
243 ...(Platform.OS === "android" && projectScopes.length === 0248 ...(Platform.OS === "android" && visibleScopes.length === 0
244 ? { flexGrow: 1, justifyContent: "center" as const }249 ? { flexGrow: 1, justifyContent: "center" as const }
245 : {}),250 : {}),
246 }}251 }}
...@@ -292,6 +297,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -292,6 +297,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
292 </Pressable>297 </Pressable>
293 )}298 )}
294 </View>299 </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>
295 ) : (309 ) : (
296 <View310 <View
297 collapsable={false}311 collapsable={false}
...@@ -301,7 +315,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -301,7 +315,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
301 : "overflow-hidden rounded-[24px] bg-card"315 : "overflow-hidden rounded-[24px] bg-card"
302 }316 }
303 >317 >
304 {projectScopes.map((scope, scopeIndex) => {318 {visibleScopes.map((scope, scopeIndex) => {
305 const hasMultipleProjects = scope.projects.length > 1;319 const hasMultipleProjects = scope.projects.length > 1;
306 const selectionTarget = getProjectScopeSelectionTarget(320 const selectionTarget = getProjectScopeSelectionTarget(
307 scope,321 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";...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
4import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";4import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import type { HomeProjectScope } from "../home/homeThreadList";5import type { HomeProjectScope } from "../home/homeThreadList";
6import {6import {
7 filterProjectScopes,
7 getProjectScopeSelectionTarget,8 getProjectScopeSelectionTarget,
8 resolveDraftProjectSelection,9 resolveDraftProjectSelection,
9 resolveEnvironmentProjectMatch,10 resolveEnvironmentProjectMatch,
...@@ -152,3 +153,31 @@ describe("resolveDraftProjectSelection", () => {...@@ -152,3 +153,31 @@ describe("resolveDraftProjectSelection", () => {
152 });153 });
153 });154 });
154});155});
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(...@@ -19,6 +19,23 @@ export function getProjectScopeSelectionTarget(
19 );19 );
20}20}
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
22function getOnlySelectableProject(39function getOnlySelectableProject(
23 projectScopes: ReadonlyArray<HomeProjectScope>,40 projectScopes: ReadonlyArray<HomeProjectScope>,
24): EnvironmentProject | null {41): EnvironmentProject | null {