authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-01 22:18:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 22:18:18-07:00
log39405f1992b4d1264730138b0fe31e07d66df4f4
tree6a4fca7456042faae4e6d38a464cbae529540002
parent3e60fecf39f37faaca9bdf37e64b7a58229ea547
signature Signed by PGP key B5690EEEBB952194

fix(mobile): one No project row at the top of the project picker (#14532)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 196 insertions(+), 105 deletions(-)

apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+46-3
......@@ -1,6 +1,8 @@
11import { MaterialListRow } from "../../components/MaterialListRow";
22import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
3import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
3import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";
5import { AsyncResult } from "effect/unstable/reactivity";
46import { LegendList } from "@legendapp/list/react-native";
57import {
68 isAtomCommandInterrupted,
......@@ -28,7 +30,8 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym
2830import { ThemedSwitch } from "../../components/ThemedSwitch";
2931import { cn } from "../../lib/cn";
3032import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
31import { useServerConfigs } from "../../state/entities";
33import { useServerConfigs, waitForProject } from "../../state/entities";
34import { projectEnvironment } from "../../state/projects";
3235import { useAtomCommand } from "../../state/use-atom-command";
3336import { vcsEnvironment } from "../../state/vcs";
3437import {
......@@ -203,6 +206,38 @@ export function NewTaskEnvironmentPickerRouteScreen() {
203206 const navigation = useNavigation();
204207 const insets = useSafeAreaInsets();
205208 const serverConfigs = useServerConfigs();
209 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {
210 reportFailure: false,
211 });
212 const [movingToEnvironmentId, setMovingToEnvironmentId] = useState<EnvironmentId | null>(null);
213
214 // A thread without a project moves to the other machine's own Scratch
215 // project, which is created there first if it does not exist yet.
216 async function moveScratchDraft(environmentId: EnvironmentId): Promise<void> {
217 setMovingToEnvironmentId(environmentId);
218 try {
219 const result = await ensureScratch({ environmentId, input: {} });
220 if (AsyncResult.isFailure(result)) {
221 const error = Cause.squash(result.cause);
222 Alert.alert(
223 "Could not switch machine",
224 error instanceof Error
225 ? error.message
226 : "The folder for threads without a project could not be created.",
227 );
228 return;
229 }
230 const project = await waitForProject({ environmentId, projectId: result.value.projectId });
231 if (project === null) {
232 Alert.alert("Could not switch machine", "It has not reached this device yet. Try again.");
233 return;
234 }
235 flow.setProject(project);
236 navigation.goBack();
237 } finally {
238 setMovingToEnvironmentId(null);
239 }
240 }
206241
207242 return (
208243 <View className="flex-1 bg-sheet" collapsable={false}>
......@@ -243,9 +278,17 @@ export function NewTaskEnvironmentPickerRouteScreen() {
243278 />
244279 }
245280 isLast={index === flow.environments.length - 1}
281 disabled={movingToEnvironmentId !== null}
246282 onPress={() => {
247283 void Haptics.selectionAsync();
248 flow.selectEnvironment(environment.environmentId);
284 if (flow.isScratchDraft) {
285 if (environment.environmentId !== flow.selectedEnvironmentId) {
286 void moveScratchDraft(environment.environmentId);
287 return;
288 }
289 } else {
290 flow.selectEnvironment(environment.environmentId);
291 }
249292 navigation.goBack();
250293 }}
251294 selected={flow.selectedEnvironmentId === environment.environmentId}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+44-20
......@@ -1455,7 +1455,49 @@ export function NewTaskDraftScreen(props: {
14551455 navigation.dispatch(StackActions.push(routeName));
14561456 };
14571457
1458 const hero = (
1458 const environmentControl = (
1459 <ComposerInlineControl
1460 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
1461 chevronDirection="right"
1462 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1463 renderIcon={(size) => (
1464 <EnvironmentMachineSymbol
1465 kind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}
1466 size={size}
1467 tintColorClassName="accent-icon-muted"
1468 />
1469 )}
1470 label={`on ${selectedEnvironmentLabel}`}
1471 maxWidth={flow.isScratchDraft ? 170 : 260}
1472 onPress={
1473 flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined
1474 }
1475 showChevron={flow.environments.length > 1}
1476 static={flow.environments.length <= 1}
1477 />
1478 );
1479 // A thread without a project has no project to name, so it asks plainly,
1480 // like web, and puts the project picker beside the machine as a control.
1481 const hero = flow.isScratchDraft ? (
1482 <View className="items-center gap-2 px-6" testID="new-task-hero">
1483 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">
1484 What should we work on?
1485 </Text>
1486 {/* Wraps onto two lines only when a long machine name leaves no room. */}
1487 <View className="flex-row flex-wrap items-center justify-center gap-x-1">
1488 <ComposerInlineControl
1489 accessibilityHint="Opens the project picker"
1490 accessibilityLabel="Choose a project"
1491 chevronDirection="right"
1492 disabled={isComposerInteractionLocked}
1493 icon="folder"
1494 label="Choose a project"
1495 onPress={chooseProject}
1496 />
1497 {environmentControl}
1498 </View>
1499 </View>
1500 ) : (
14591501 <View className="items-center gap-6 px-6" testID="new-task-hero">
14601502 <View className="w-full items-center gap-1.5">
14611503 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">
......@@ -1482,25 +1524,7 @@ export function NewTaskDraftScreen(props: {
14821524 </View>
14831525 </View>
14841526
1485 <ComposerInlineControl
1486 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
1487 chevronDirection="right"
1488 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1489 renderIcon={(size) => (
1490 <EnvironmentMachineSymbol
1491 kind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}
1492 size={size}
1493 tintColorClassName="accent-icon-muted"
1494 />
1495 )}
1496 label={`on ${selectedEnvironmentLabel}`}
1497 maxWidth={260}
1498 onPress={
1499 flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined
1500 }
1501 showChevron={flow.environments.length > 1}
1502 static={flow.environments.length <= 1}
1503 />
1527 {environmentControl}
15041528 </View>
15051529 );
15061530 const heroViewport = (
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+65-74
......@@ -154,7 +154,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
154154 : null;
155155 const screenTitle = incomingShare ? "Start a task" : "Choose project";
156156 const projectEmptyState = deriveProjectEmptyState(catalogState);
157 const visibleScopes = filterProjectScopes(projectScopes, searchText);
157 const serverConfigs = useServerConfigs();
158 // Scratch projects are reached through the No project row, never as rows
159 // of their own.
160 const listScopes = projectScopes.filter(
161 (scope) =>
162 !scope.projects.every((project) =>
163 isScratchProject(project, serverConfigs.get(project.environmentId)?.scratchWorkspaceRoot),
164 ),
165 );
166 const visibleScopes = filterProjectScopes(listScopes, searchText);
158167 const resumedDestinationKeyRef = useRef<string | null>(null);
159168 const reservedDestinationProject = incomingShare?.destination
160169 ? (projects.find(
......@@ -163,14 +172,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
163172 project.id === incomingShare.destination?.projectId,
164173 ) ?? null)
165174 : null;
166 const serverConfigs = useServerConfigs();
167175 const { connectedEnvironments } = useRemoteConnectionStatus();
168176 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {
169177 reportFailure: false,
170178 });
171179 // Threads without a project need a connected environment that offers them.
172 // The selected environment wins when it has one; otherwise the first that
173 // does, and the entry names that machine whenever there is a choice.
180 // The row starts on the selected environment when it has one, otherwise the
181 // first that does; the draft page's machine picker moves it from there.
174182 const scratchEnvironments = connectedEnvironments.filter(
175183 (environment) =>
176184 canCreateProjectInEnvironment(environment.connectionState) &&
......@@ -182,24 +190,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
182190 ) ??
183191 scratchEnvironments[0] ??
184192 null;
185 const scratchWorkspaceRoot = scratchEnvironment
186 ? (serverConfigs.get(scratchEnvironment.environmentId)?.scratchWorkspaceRoot ?? null)
187 : null;
188 // Once the Scratch project exists it is an ordinary row in the list.
189 const scratchProjectExists = projects.some(
190 (project) =>
191 project.environmentId === scratchEnvironment?.environmentId &&
192 isScratchProject(project, scratchWorkspaceRoot),
193 );
194 const canStartScratch = scratchWorkspaceRoot !== null && reservedDestinationProject === null;
195 const scratchMachineLabel =
196 connectedEnvironments.length > 1 ? (scratchEnvironment?.environmentLabel ?? null) : null;
197 const startScratchLabel = scratchMachineLabel
198 ? `Start without a project on ${scratchMachineLabel}`
199 : "Start without a project";
200 const scratchRowSubtitle = scratchMachineLabel
201 ? `On ${scratchMachineLabel}`
202 : "Start a task without a project";
193 const canStartScratch = scratchEnvironment !== null && reservedDestinationProject === null;
203194 const scratchStartInFlightRef = useRef(false);
204195
205196 async function selectProject(project: EnvironmentProject): Promise<void> {
......@@ -321,7 +312,56 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
321312 : {}),
322313 }}
323314 >
324 {projectScopes.length === 0 ? (
315 {canStartScratch && listScopes.length > 0 ? (
316 Platform.OS === "android" ? (
317 <View collapsable={false} className="overflow-hidden rounded-[28px] bg-card">
318 <MaterialListRow
319 title="No project"
320 subtitle="Start a task without a project"
321 onPress={() => void startScratch()}
322 leading={
323 <SymbolView
324 name="text.bubble"
325 size={22}
326 tintColorClassName="accent-icon-muted"
327 type="monochrome"
328 />
329 }
330 />
331 </View>
332 ) : (
333 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
334 <Pressable
335 accessibilityRole="button"
336 accessibilityLabel="No project"
337 onPress={() => void startScratch()}
338 className="flex-row items-center gap-3 bg-card px-4 py-3.5"
339 >
340 <View className="h-7 w-7 items-center justify-center">
341 <SymbolView
342 name="text.bubble"
343 size={18}
344 tintColorClassName="accent-icon-muted"
345 type="monochrome"
346 />
347 </View>
348 <View className="min-w-0 flex-1">
349 <Text className="text-base font-t3-bold leading-snug">No project</Text>
350 <Text className="text-xs leading-snug text-foreground-muted" numberOfLines={1}>
351 Start a task without a project
352 </Text>
353 </View>
354 <SymbolView
355 name="chevron.right"
356 size={14}
357 tintColorClassName="accent-chevron"
358 type="monochrome"
359 />
360 </Pressable>
361 </View>
362 )
363 ) : null}
364 {listScopes.length === 0 ? (
325365 <View
326366 collapsable={false}
327367 className={cn(
......@@ -351,7 +391,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
351391 />
352392 {canStartScratch ? (
353393 <MaterialButton
354 label={startScratchLabel}
394 label="Start without a project"
355395 tone="secondary"
356396 onPress={() => void startScratch()}
357397 />
......@@ -382,7 +422,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
382422 onPress={() => void startScratch()}
383423 >
384424 <Text className="text-sm font-t3-bold text-foreground">
385 {startScratchLabel}
425 Start without a project
386426 </Text>
387427 </Pressable>
388428 ) : null}
......@@ -486,55 +526,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
486526 })}
487527 </View>
488528 )}
489 {canStartScratch && !scratchProjectExists && projectScopes.length > 0 ? (
490 Platform.OS === "android" ? (
491 <View collapsable={false} className="overflow-hidden rounded-[28px] bg-card">
492 <MaterialListRow
493 title="No project"
494 subtitle={scratchRowSubtitle}
495 onPress={() => void startScratch()}
496 leading={
497 <SymbolView
498 name="text.bubble"
499 size={22}
500 tintColorClassName="accent-icon-muted"
501 type="monochrome"
502 />
503 }
504 />
505 </View>
506 ) : (
507 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
508 <Pressable
509 accessibilityRole="button"
510 accessibilityLabel="No project"
511 onPress={() => void startScratch()}
512 className="flex-row items-center gap-3 bg-card px-4 py-3.5"
513 >
514 <View className="h-7 w-7 items-center justify-center">
515 <SymbolView
516 name="text.bubble"
517 size={18}
518 tintColorClassName="accent-icon-muted"
519 type="monochrome"
520 />
521 </View>
522 <View className="min-w-0 flex-1">
523 <Text className="text-base font-t3-bold leading-snug">No project</Text>
524 <Text className="text-xs leading-snug text-foreground-muted" numberOfLines={1}>
525 {scratchRowSubtitle}
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 ) : null}
538529 </ScrollView>
539530 </MaterialScreenContent>
540531 </View>
apps/mobile/src/features/threads/new-task-flow-provider.tsx+41-8
......@@ -24,7 +24,12 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
2424import * as Arr from "effect/Array";
2525import { pipe } from "effect/Function";
2626
27import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
27import {
28 useEnvironmentServerConfig,
29 useProjects,
30 useServerConfigs,
31 useThreadShells,
32} from "../../state/entities";
2833import type { TurnCommandMetadata } from "../../lib/commandMetadata";
2934import type { DraftComposerAttachment } from "../../lib/composerImages";
3035import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
......@@ -77,8 +82,10 @@ import {
7782} from "../../state/use-thread-outbox";
7883import {
7984 setPendingConnectionError,
85 useRemoteConnectionStatus,
8086 useSavedRemoteConnections,
8187} from "../../state/use-remote-environment-registry";
88import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
8289import { isScratchProject } from "@t3tools/client-runtime/state/projects";
8390import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
8491import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
......@@ -173,6 +180,8 @@ type NewTaskFlowContextValue = {
173180 readonly environmentLabel: string;
174181 }>;
175182 readonly selectedProject: EnvironmentProject | null;
183 /** True when the draft is a thread without a project (its machine's Scratch project). */
184 readonly isScratchDraft: boolean;
176185 readonly modelOptions: ReadonlyArray<ModelOption>;
177186 readonly selectedModel: ModelSelection | null;
178187 readonly selectedModelOption: ModelOption | null;
......@@ -339,6 +348,26 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
339348 ? editingPendingProject
340349 : (projectsForEnvironment[0] ?? null));
341350
351 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
352 selectedProject?.environmentId ?? null,
353 );
354 const isScratchDraft =
355 selectedProject !== null &&
356 isScratchProject(selectedProject, selectedEnvironmentServerConfig?.scratchWorkspaceRoot);
357 const serverConfigs = useServerConfigs();
358 const { connectedEnvironments } = useRemoteConnectionStatus();
359 // A thread without a project can move to any connected machine that offers
360 // one; its Scratch project there is created on the switch if it is missing.
361 const scratchEnvironments = useMemo(
362 () =>
363 connectedEnvironments.filter(
364 (environment) =>
365 canCreateProjectInEnvironment(environment.connectionState) &&
366 serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined,
367 ),
368 [connectedEnvironments, serverConfigs],
369 );
370
342371 // Only offer machines that actually host the currently selected repository, so
343372 // switching computers moves the same repo across machines instead of jumping to
344373 // whatever unrelated project happens to be first on the other machine. Repository
......@@ -350,6 +379,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
350379 const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
351380 const selectedProjectTitle = selectedProject?.title ?? null;
352381 const environments = useMemo(() => {
382 if (isScratchDraft) {
383 return scratchEnvironments.map((environment) => ({
384 environmentId: environment.environmentId,
385 environmentLabel: environment.environmentLabel,
386 }));
387 }
353388 const seen = new Set<EnvironmentId>();
354389 const result: Array<{
355390 readonly environmentId: EnvironmentId;
......@@ -387,16 +422,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
387422 }
388423 return result;
389424 }, [
425 isScratchDraft,
390426 projects,
391427 savedConnectionsById,
428 scratchEnvironments,
392429 selectedRepositoryKey,
393430 selectedWorkspaceBasename,
394431 selectedProjectTitle,
395432 ]);
396433
397 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
398 selectedProject?.environmentId ?? null,
399 );
400434 // While a queued pending task is being edited its draft lives under a key
401435 // scoped to the queued message, so new-task drafts stay intact.
402436 const selectedProjectDraftKey = editingPendingTask
......@@ -457,10 +491,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
457491 );
458492 // A thread without a project runs in a plain folder, so worktree mode
459493 // would leave it unsendable: it is always local and offers no choice.
460 const canChooseWorkspace = !(
461 selectedProject !== null &&
462 isScratchProject(selectedProject, selectedEnvironmentServerConfig?.scratchWorkspaceRoot)
463 );
494 const canChooseWorkspace = !isScratchDraft;
464495 const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace
465496 ? projectSettings.settings.defaultThreadEnvMode
466497 : "local";
......@@ -1192,6 +1223,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
11921223 expandedProvider,
11931224 environments,
11941225 selectedProject,
1226 isScratchDraft,
11951227 modelOptions,
11961228 selectedModel,
11971229 selectedModelOption,
......@@ -1241,6 +1273,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
12411273 filteredBranches,
12421274 finishEditingPendingTask,
12431275 interactionMode,
1276 isScratchDraft,
12441277 planModeEnabled,
12451278 loadBranches,
12461279 loadMoreBranches,