1import { AppActivityIndicator } from "../../components/AppActivityIndicator";
2import { MaterialListRow } from "../../components/MaterialListRow";
3import { ScreenHeader } from "../../components/ScreenHeader";
4import {
5 StackActions,
6 useIsFocused,
7 useNavigation,
8 type StaticScreenProps,
9} from "@react-navigation/native";
10import { SymbolView } from "../../components/AppSymbol";
11import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
12import { isScratchProject } from "@t3tools/client-runtime/state/projects";
13import {
14 isAtomCommandInterrupted,
15 squashAtomCommandFailure,
16} from "@t3tools/client-runtime/state/runtime";
17import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
18import { useEffect, useRef, useState } from "react";
19import { Alert, Platform, Pressable, View } from "react-native";
20import { useSafeAreaInsets } from "react-native-safe-area-context";
21import { cn } from "../../lib/cn";
22import { MaterialScreenContent } from "../../components/MaterialScreenContent";
23import { MaterialButton } from "../../components/MaterialButton";
24import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
25import { AppText as Text } from "../../components/AppText";
26import { ProjectFavicon } from "../../components/ProjectFavicon";
27import { useProjects, useServerConfigs } from "../../state/entities";
28import { projectEnvironment } from "../../state/projects";
29import { useAtomCommand } from "../../state/use-atom-command";
30import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
31import type { WorkspaceState } from "../../state/workspaceModel";
32import { useWorkspaceState } from "../../state/workspace";
33import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
34import { usePromptLinkResolver } from "../prompt-links/usePromptLinkResolver";
35import { useIncomingShare } from "../sharing/IncomingShareProvider";
36import { useNewTaskFlow } from "./new-task-flow-provider";
37import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection";
38
39type NewTaskRouteParams = {
40 readonly incomingShareId?: string | string[];
41 // A `t3code://new?…` prompt link. See usePromptLinkResolver.
42 readonly prompt?: string | string[];
43 readonly repo?: string | string[];
44 readonly pr?: string | string[];
45 readonly provider?: string | string[];
46 readonly model?: string | string[];
47};
48
49function deriveProjectEmptyState(catalogState: WorkspaceState): {
50 readonly title: string;
51 readonly detail: string;
52 readonly loading: boolean;
53} {
54 if (catalogState.isLoadingConnections) {
55 return {
56 title: "Loading environments",
57 detail: "Checking saved environments on this device.",
58 loading: true,
59 };
60 }
61
62 if (!catalogState.hasConnections) {
63 return {
64 title: "No environments connected",
65 detail: "Add an environment before creating a task.",
66 loading: false,
67 };
68 }
69
70 if (
71 (catalogState.connectionState === "available" ||
72 catalogState.connectionState === "offline" ||
73 catalogState.connectionState === "error") &&
74 !catalogState.hasLoadedShellSnapshot
75 ) {
76 return {
77 title: "Environment unavailable",
78 detail:
79 catalogState.connectionError ??
80 "The saved environment is offline. Check the URL or start the environment, then retry.",
81 loading: false,
82 };
83 }
84
85 if (
86 catalogState.hasConnectingEnvironment &&
87 !catalogState.hasLoadedShellSnapshot &&
88 catalogState.connectionError === null
89 ) {
90 return {
91 title: "Connecting to environment",
92 detail: "Loading projects from the saved environment.",
93 loading: true,
94 };
95 }
96
97 return {
98 title: "No projects found",
99 detail: "The connected environment did not report any projects.",
100 loading: false,
101 };
102}
103
104function NewTaskHeader(props: {
105 readonly title: string;
106 readonly subtitle: string | null;
107 readonly canAddProject: boolean;
108 readonly searchText: string;
109 readonly onSearchTextChange: (text: string) => void;
110}) {
111 const navigation = useNavigation();
112 const { layout } = useAdaptiveWorkspaceLayout();
113 return (
114 <ScreenHeader
115 title={props.title}
116 subtitle={props.subtitle ?? undefined}
117 sidebar={false}
118 backInSplitView={{
119 accessibilityLabel: "Go back",
120 icon: "chevron.left",
121 }}
122 options={{ headerBackVisible: !layout.usesSplitView }}
123 hideBottomBorder
124 onBack={() => navigation.goBack()}
125 actions={
126 props.canAddProject
127 ? [
128 {
129 accessibilityLabel: "Add project",
130 icon: "plus",
131 onPress: () => navigation.dispatch(StackActions.push("AddProject")),
132 },
133 ]
134 : []
135 }
136 search={{
137 value: props.searchText,
138 onChangeText: props.onSearchTextChange,
139 placeholder: "Search projects",
140 }}
141 />
142 );
143}
144
145export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {
146 const projects = useProjects();
147 const [searchText, setSearchText] = useState("");
148 const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow();
149 const { state: catalogState } = useWorkspaceState();
150 const navigation = useNavigation();
151 const isFocused = useIsFocused();
152 const insets = useSafeAreaInsets();
153 const { getShare, releaseShareReservation } = useIncomingShare();
154 usePromptLinkResolver(route.params);
155 const routeShareId = Array.isArray(route.params?.incomingShareId)
156 ? route.params.incomingShareId[0]
157 : route.params?.incomingShareId;
158 const incomingShare = routeShareId ? getShare(routeShareId) : null;
159 const incomingShareSubtitle = incomingShare
160 ? incomingShare.attachments.length === 0
161 ? "Choose a project for what you shared"
162 : incomingShare.attachments.length === 1
163 ? `Choose a project for the ${incomingShare.attachments[0]?.type === "image" ? "image" : "file"} you shared`
164 : `Choose a project for the ${incomingShare.attachments.length} ${incomingShare.attachments.every((attachment) => attachment.type === "image") ? "images" : "files"} you shared`
165 : null;
166 const screenTitle = incomingShare ? "Start a task" : "Choose project";
167 const projectEmptyState = deriveProjectEmptyState(catalogState);
168 const serverConfigs = useServerConfigs();
169 // Scratch projects are reached through the No project row, never as rows
170 // of their own.
171 const listScopes = projectScopes.filter(
172 (scope) =>
173 !scope.projects.every((project) =>
174 isScratchProject(project, serverConfigs.get(project.environmentId)?.scratchWorkspaceRoot),
175 ),
176 );
177 const visibleScopes = filterProjectScopes(listScopes, searchText);
178 const resumedDestinationKeyRef = useRef<string | null>(null);
179 const reservedDestinationProject = incomingShare?.destination
180 ? (projects.find(
181 (project) =>
182 project.environmentId === incomingShare.destination?.environmentId &&
183 project.id === incomingShare.destination?.projectId,
184 ) ?? null)
185 : null;
186 const { connectedEnvironments } = useRemoteConnectionStatus();
187 const openScratch = useAtomCommand(projectEnvironment.openScratch, {
188 reportFailure: false,
189 });
190 // Threads without a project need a connected environment that offers them.
191 // The row starts on the selected environment when it has one, otherwise the
192 // first that does; the draft page's machine picker moves it from there.
193 const scratchEnvironments = connectedEnvironments.filter(
194 (environment) =>
195 availableScratchWorkspaceRoot(
196 environment.connectionState,
197 serverConfigs.get(environment.environmentId),
198 ) !== null,
199 );
200 const scratchEnvironment =
201 scratchEnvironments.find(
202 (environment) => environment.environmentId === selectedEnvironmentId,
203 ) ??
204 scratchEnvironments[0] ??
205 null;
206 const canStartScratch = scratchEnvironment !== null && reservedDestinationProject === null;
207 const scratchStartInFlightRef = useRef(false);
208
209 async function selectProject(project: EnvironmentProject): Promise<void> {
210 if (incomingShare?.destination && !reservedDestinationProject) {
211 try {
212 await releaseShareReservation(incomingShare.id, incomingShare.destination);
213 } catch (error) {
214 Alert.alert(
215 "Could not change project",
216 error instanceof Error
217 ? error.message
218 : "The shared content reservation could not be updated.",
219 );
220 return;
221 }
222 }
223 const state = navigation.getState();
224 const previousRoute = state?.routes[state.index - 1];
225 if (previousRoute?.name === "NewTaskDraft") {
226 setProject(project);
227 navigation.goBack();
228 return;
229 }
230
231 navigation.dispatch(
232 StackActions.push("NewTaskDraft", {
233 environmentId: project.environmentId,
234 projectId: project.id,
235 title: project.title,
236 incomingShareId: incomingShare?.id,
237 }),
238 );
239 }
240
241 async function startScratch(): Promise<void> {
242 if (!scratchEnvironment || scratchStartInFlightRef.current) return;
243 const environmentId = scratchEnvironment.environmentId;
244 scratchStartInFlightRef.current = true;
245 try {
246 const result = await openScratch({ environmentId, input: {} });
247 if (result._tag === "Failure") {
248 if (!isAtomCommandInterrupted(result)) {
249 const error = squashAtomCommandFailure(result);
250 Alert.alert(
251 "Could not start without a project",
252 error instanceof Error
253 ? error.message
254 : "The folder for threads without a project could not be created.",
255 );
256 }
257 return;
258 }
259 await selectProject(result.value);
260 } finally {
261 scratchStartInFlightRef.current = false;
262 }
263 }
264
265 useEffect(() => {
266 const destination = incomingShare?.destination;
267 if (!destination) {
268 resumedDestinationKeyRef.current = null;
269 return;
270 }
271 if (!isFocused) {
272 // Returning from the reserved draft is a fresh resume attempt. Keeping
273 // this latch set would leave every project row disabled with no route.
274 resumedDestinationKeyRef.current = null;
275 return;
276 }
277 const destinationKey = `${incomingShare.id}:${destination.environmentId}:${destination.projectId}`;
278 if (resumedDestinationKeyRef.current === destinationKey) {
279 return;
280 }
281 if (!reservedDestinationProject) {
282 return;
283 }
284 resumedDestinationKeyRef.current = destinationKey;
285 navigation.dispatch(
286 StackActions.push("NewTaskDraft", {
287 environmentId: reservedDestinationProject.environmentId,
288 projectId: reservedDestinationProject.id,
289 title: reservedDestinationProject.title,
290 incomingShareId: incomingShare.id,
291 }),
292 );
293 }, [incomingShare, isFocused, navigation, reservedDestinationProject]);
294
295 return (
296 <View collapsable={false} className="flex-1 bg-sheet">
297 <NewTaskHeader
298 title={screenTitle}
299 subtitle={incomingShareSubtitle}
300 canAddProject={catalogState.hasReadyEnvironment}
301 searchText={searchText}
302 onSearchTextChange={setSearchText}
303 />
304
305 <MaterialScreenContent>
306 <ScrollView
307 contentInsetAdjustmentBehavior="automatic"
308 showsVerticalScrollIndicator={false}
309 keyboardShouldPersistTaps="handled"
310 keyboardDismissMode="on-drag"
311 className="flex-1"
312 contentContainerStyle={{
313 gap: Platform.OS === "android" ? 8 : 12,
314 paddingBottom: Math.max(insets.bottom, 18) + 18,
315 paddingHorizontal: Platform.OS === "android" ? 16 : 20,
316 paddingTop: Platform.OS === "android" ? 16 : 8,
317 ...(Platform.OS === "android" && visibleScopes.length === 0
318 ? { flexGrow: 1, justifyContent: "center" as const }
319 : {}),
320 }}
321 >
322 {canStartScratch && listScopes.length > 0 ? (
323 Platform.OS === "android" ? (
324 <View collapsable={false} className="overflow-hidden rounded-[28px] bg-grouped-card">
325 <MaterialListRow
326 className="bg-grouped-card"
327 title="No project"
328 subtitle="Start a task without a project"
329 onPress={() => void startScratch()}
330 leading={
331 <SymbolView
332 name="text.bubble"
333 size={22}
334 tintColorClassName="accent-icon-muted"
335 type="monochrome"
336 />
337 }
338 />
339 </View>
340 ) : (
341 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card">
342 <Pressable
343 accessibilityRole="button"
344 accessibilityLabel="No project"
345 onPress={() => void startScratch()}
346 className="flex-row items-center gap-3 bg-grouped-card px-4 py-3.5"
347 >
348 <View className="h-7 w-7 items-center justify-center">
349 <SymbolView
350 name="text.bubble"
351 size={18}
352 tintColorClassName="accent-icon-muted"
353 type="monochrome"
354 />
355 </View>
356 <View className="min-w-0 flex-1">
357 <Text className="text-base font-t3-bold leading-snug">No project</Text>
358 <Text className="text-xs leading-snug text-foreground-muted" numberOfLines={1}>
359 Start a task without a project
360 </Text>
361 </View>
362 <SymbolView
363 name="chevron.right"
364 size={14}
365 tintColorClassName="accent-chevron"
366 type="monochrome"
367 />
368 </Pressable>
369 </View>
370 )
371 ) : null}
372 {listScopes.length === 0 ? (
373 <View
374 collapsable={false}
375 className={cn(
376 "items-center gap-3 px-6 py-8",
377 Platform.OS !== "android" && "rounded-[24px] bg-grouped-card",
378 )}
379 >
380 {projectEmptyState.loading ? (
381 <AppActivityIndicator colorClassName="accent-icon-muted" />
382 ) : null}
383 <Text className="text-center text-lg font-t3-bold text-foreground">
384 {projectEmptyState.title}
385 </Text>
386 <Text className="text-center text-sm leading-normal text-foreground-muted">
387 {projectEmptyState.detail}
388 </Text>
389 {Platform.OS === "android" ? (
390 <>
391 <MaterialButton
392 label={catalogState.hasReadyEnvironment ? "Add new project" : "Add environment"}
393 tone="primary"
394 onPress={() =>
395 catalogState.hasReadyEnvironment
396 ? navigation.dispatch(StackActions.push("AddProject"))
397 : navigation.navigate("ConnectionsNew")
398 }
399 />
400 {canStartScratch ? (
401 <MaterialButton
402 label="Start without a project"
403 tone="secondary"
404 onPress={() => void startScratch()}
405 />
406 ) : null}
407 </>
408 ) : !catalogState.hasReadyEnvironment ? (
409 <Pressable
410 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
411 onPress={() => navigation.navigate("ConnectionsNew")}
412 >
413 <Text className="text-sm font-t3-bold text-primary-foreground">
414 Add environment
415 </Text>
416 </Pressable>
417 ) : (
418 <>
419 <Pressable
420 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
421 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
422 >
423 <Text className="text-sm font-t3-bold text-primary-foreground">
424 Add new project
425 </Text>
426 </Pressable>
427 {canStartScratch ? (
428 <Pressable
429 className="rounded-full bg-subtle px-4 py-2.5 active:opacity-70"
430 onPress={() => void startScratch()}
431 >
432 <Text className="text-sm font-t3-bold text-foreground">
433 Start without a project
434 </Text>
435 </Pressable>
436 ) : null}
437 </>
438 )}
439 </View>
440 ) : visibleScopes.length === 0 ? (
441 <View className="items-center gap-2 px-6 py-8">
442 <Text className="text-center text-lg font-t3-bold text-foreground">
443 No matching projects
444 </Text>
445 <Text className="text-center text-sm leading-normal text-foreground-muted">
446 Try a different project name or workspace path.
447 </Text>
448 </View>
449 ) : (
450 <View
451 collapsable={false}
452 className={
453 Platform.OS === "android"
454 ? "overflow-hidden rounded-[28px] bg-grouped-card"
455 : "overflow-hidden rounded-[24px] bg-grouped-card"
456 }
457 >
458 {visibleScopes.map((scope, scopeIndex) => {
459 const hasMultipleProjects = scope.projects.length > 1;
460 const selectionTarget = getProjectScopeSelectionTarget(
461 scope,
462 selectedEnvironmentId,
463 );
464 if (Platform.OS === "android") {
465 return (
466 <MaterialListRow
467 className="bg-grouped-card"
468 key={scope.key}
469 title={scope.title}
470 subtitle={
471 hasMultipleProjects
472 ? `${scope.projects.length} workspaces`
473 : selectionTarget.workspaceRoot
474 }
475 subtitleNumberOfLines={1}
476 subtitleEllipsizeMode="middle"
477 disabled={reservedDestinationProject !== null}
478 onPress={() => void selectProject(selectionTarget)}
479 leading={
480 <ProjectFavicon
481 environmentId={scope.representative.environmentId}
482 faviconPath={scope.representative.faviconPath}
483 projectIcon={scope.representative.projectIcon}
484 size={24}
485 projectTitle={scope.title}
486 workspaceRoot={scope.representative.workspaceRoot}
487 />
488 }
489 />
490 );
491 }
492 return (
493 <View
494 key={scope.key}
495 className={cn(scopeIndex > 0 && "border-t border-border-subtle")}
496 >
497 <Pressable
498 accessibilityRole="button"
499 accessibilityLabel={scope.title}
500 disabled={reservedDestinationProject !== null}
501 onPress={() => void selectProject(selectionTarget)}
502 className="flex-row items-center gap-3 bg-grouped-card px-4 py-3.5"
503 >
504 <View className="h-7 w-7 items-center justify-center">
505 <ProjectFavicon
506 environmentId={scope.representative.environmentId}
507 faviconPath={scope.representative.faviconPath}
508 projectIcon={scope.representative.projectIcon}
509 size={20}
510 projectTitle={scope.title}
511 workspaceRoot={scope.representative.workspaceRoot}
512 />
513 </View>
514 <View className="min-w-0 flex-1">
515 <Text className={cn("text-base leading-snug", "font-t3-bold")}>
516 {scope.title}
517 </Text>
518 <Text
519 className="text-xs leading-snug text-foreground-muted"
520 ellipsizeMode="middle"
521 numberOfLines={1}
522 >
523 {hasMultipleProjects
524 ? `${scope.projects.length} workspaces`
525 : selectionTarget.workspaceRoot}
526 </Text>
527 </View>
528 <SymbolView
529 name="chevron.right"
530 size={14}
531 tintColorClassName="accent-chevron"
532 type="monochrome"
533 />
534 </Pressable>
535 </View>
536 );
537 })}
538 </View>
539 )}
540 </ScrollView>
541 </MaterialScreenContent>
542 </View>
543 );
544}