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 @@...@@ -1,6 +1,8 @@
1import { MaterialListRow } from "../../components/MaterialListRow";1import { MaterialListRow } from "../../components/MaterialListRow";
2import type { VcsRef } from "@t3tools/client-runtime/state/vcs";2import 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";
4import { LegendList } from "@legendapp/list/react-native";6import { LegendList } from "@legendapp/list/react-native";
5import {7import {
6 isAtomCommandInterrupted,8 isAtomCommandInterrupted,
...@@ -28,7 +30,8 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym...@@ -28,7 +30,8 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym
28import { ThemedSwitch } from "../../components/ThemedSwitch";30import { ThemedSwitch } from "../../components/ThemedSwitch";
29import { cn } from "../../lib/cn";31import { cn } from "../../lib/cn";
30import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";32import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
31import { useServerConfigs } from "../../state/entities";33import { useServerConfigs, waitForProject } from "../../state/entities";
34import { projectEnvironment } from "../../state/projects";
32import { useAtomCommand } from "../../state/use-atom-command";35import { useAtomCommand } from "../../state/use-atom-command";
33import { vcsEnvironment } from "../../state/vcs";36import { vcsEnvironment } from "../../state/vcs";
34import {37import {
...@@ -203,6 +206,38 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -203,6 +206,38 @@ export function NewTaskEnvironmentPickerRouteScreen() {
203 const navigation = useNavigation();206 const navigation = useNavigation();
204 const insets = useSafeAreaInsets();207 const insets = useSafeAreaInsets();
205 const serverConfigs = useServerConfigs();208 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
207 return (242 return (
208 <View className="flex-1 bg-sheet" collapsable={false}>243 <View className="flex-1 bg-sheet" collapsable={false}>
...@@ -243,9 +278,17 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -243,9 +278,17 @@ export function NewTaskEnvironmentPickerRouteScreen() {
243 />278 />
244 }279 }
245 isLast={index === flow.environments.length - 1}280 isLast={index === flow.environments.length - 1}
281 disabled={movingToEnvironmentId !== null}
246 onPress={() => {282 onPress={() => {
247 void Haptics.selectionAsync();283 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 }
249 navigation.goBack();292 navigation.goBack();
250 }}293 }}
251 selected={flow.selectedEnvironmentId === environment.environmentId}294 selected={flow.selectedEnvironmentId === environment.environmentId}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+44-20
...@@ -1455,7 +1455,49 @@ export function NewTaskDraftScreen(props: {...@@ -1455,7 +1455,49 @@ export function NewTaskDraftScreen(props: {
1455 navigation.dispatch(StackActions.push(routeName));1455 navigation.dispatch(StackActions.push(routeName));
1456 };1456 };
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 ) : (
1459 <View className="items-center gap-6 px-6" testID="new-task-hero">1501 <View className="items-center gap-6 px-6" testID="new-task-hero">
1460 <View className="w-full items-center gap-1.5">1502 <View className="w-full items-center gap-1.5">
1461 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">1503 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">
...@@ -1482,25 +1524,7 @@ export function NewTaskDraftScreen(props: {...@@ -1482,25 +1524,7 @@ export function NewTaskDraftScreen(props: {
1482 </View>1524 </View>
1483 </View>1525 </View>
14841526
1485 <ComposerInlineControl1527 {environmentControl}
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 />
1504 </View>1528 </View>
1505 );1529 );
1506 const heroViewport = (1530 const heroViewport = (
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+65-74
...@@ -154,7 +154,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -154,7 +154,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
154 : null;154 : null;
155 const screenTitle = incomingShare ? "Start a task" : "Choose project";155 const screenTitle = incomingShare ? "Start a task" : "Choose project";
156 const projectEmptyState = deriveProjectEmptyState(catalogState);156 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);
158 const resumedDestinationKeyRef = useRef<string | null>(null);167 const resumedDestinationKeyRef = useRef<string | null>(null);
159 const reservedDestinationProject = incomingShare?.destination168 const reservedDestinationProject = incomingShare?.destination
160 ? (projects.find(169 ? (projects.find(
...@@ -163,14 +172,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -163,14 +172,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
163 project.id === incomingShare.destination?.projectId,172 project.id === incomingShare.destination?.projectId,
164 ) ?? null)173 ) ?? null)
165 : null;174 : null;
166 const serverConfigs = useServerConfigs();
167 const { connectedEnvironments } = useRemoteConnectionStatus();175 const { connectedEnvironments } = useRemoteConnectionStatus();
168 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {176 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {
169 reportFailure: false,177 reportFailure: false,
170 });178 });
171 // Threads without a project need a connected environment that offers them.179 // Threads without a project need a connected environment that offers them.
172 // The selected environment wins when it has one; otherwise the first that180 // The row starts on the selected environment when it has one, otherwise the
173 // does, and the entry names that machine whenever there is a choice.181 // first that does; the draft page's machine picker moves it from there.
174 const scratchEnvironments = connectedEnvironments.filter(182 const scratchEnvironments = connectedEnvironments.filter(
175 (environment) =>183 (environment) =>
176 canCreateProjectInEnvironment(environment.connectionState) &&184 canCreateProjectInEnvironment(environment.connectionState) &&
...@@ -182,24 +190,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -182,24 +190,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
182 ) ??190 ) ??
183 scratchEnvironments[0] ??191 scratchEnvironments[0] ??
184 null;192 null;
185 const scratchWorkspaceRoot = scratchEnvironment193 const canStartScratch = scratchEnvironment !== null && reservedDestinationProject === null;
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";
203 const scratchStartInFlightRef = useRef(false);194 const scratchStartInFlightRef = useRef(false);
204195
205 async function selectProject(project: EnvironmentProject): Promise<void> {196 async function selectProject(project: EnvironmentProject): Promise<void> {
...@@ -321,7 +312,56 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -321,7 +312,56 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
321 : {}),312 : {}),
322 }}313 }}
323 >314 >
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 ? (
325 <View365 <View
326 collapsable={false}366 collapsable={false}
327 className={cn(367 className={cn(
...@@ -351,7 +391,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -351,7 +391,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
351 />391 />
352 {canStartScratch ? (392 {canStartScratch ? (
353 <MaterialButton393 <MaterialButton
354 label={startScratchLabel}394 label="Start without a project"
355 tone="secondary"395 tone="secondary"
356 onPress={() => void startScratch()}396 onPress={() => void startScratch()}
357 />397 />
...@@ -382,7 +422,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -382,7 +422,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
382 onPress={() => void startScratch()}422 onPress={() => void startScratch()}
383 >423 >
384 <Text className="text-sm font-t3-bold text-foreground">424 <Text className="text-sm font-t3-bold text-foreground">
385 {startScratchLabel}425 Start without a project
386 </Text>426 </Text>
387 </Pressable>427 </Pressable>
388 ) : null}428 ) : null}
...@@ -486,55 +526,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -486,55 +526,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
486 })}526 })}
487 </View>527 </View>
488 )}528 )}
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}
538 </ScrollView>529 </ScrollView>
539 </MaterialScreenContent>530 </MaterialScreenContent>
540 </View>531 </View>
apps/mobile/src/features/threads/new-task-flow-provider.tsx+41-8
...@@ -24,7 +24,12 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";...@@ -24,7 +24,12 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
24import * as Arr from "effect/Array";24import * as Arr from "effect/Array";
25import { pipe } from "effect/Function";25import { 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";
28import type { TurnCommandMetadata } from "../../lib/commandMetadata";33import type { TurnCommandMetadata } from "../../lib/commandMetadata";
29import type { DraftComposerAttachment } from "../../lib/composerImages";34import type { DraftComposerAttachment } from "../../lib/composerImages";
30import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";35import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
...@@ -77,8 +82,10 @@ import {...@@ -77,8 +82,10 @@ import {
77} from "../../state/use-thread-outbox";82} from "../../state/use-thread-outbox";
78import {83import {
79 setPendingConnectionError,84 setPendingConnectionError,
85 useRemoteConnectionStatus,
80 useSavedRemoteConnections,86 useSavedRemoteConnections,
81} from "../../state/use-remote-environment-registry";87} from "../../state/use-remote-environment-registry";
88import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
82import { isScratchProject } from "@t3tools/client-runtime/state/projects";89import { isScratchProject } from "@t3tools/client-runtime/state/projects";
83import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";90import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
84import { type VcsRef } from "@t3tools/client-runtime/state/vcs";91import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
...@@ -173,6 +180,8 @@ type NewTaskFlowContextValue = {...@@ -173,6 +180,8 @@ type NewTaskFlowContextValue = {
173 readonly environmentLabel: string;180 readonly environmentLabel: string;
174 }>;181 }>;
175 readonly selectedProject: EnvironmentProject | null;182 readonly selectedProject: EnvironmentProject | null;
183 /** True when the draft is a thread without a project (its machine's Scratch project). */
184 readonly isScratchDraft: boolean;
176 readonly modelOptions: ReadonlyArray<ModelOption>;185 readonly modelOptions: ReadonlyArray<ModelOption>;
177 readonly selectedModel: ModelSelection | null;186 readonly selectedModel: ModelSelection | null;
178 readonly selectedModelOption: ModelOption | null;187 readonly selectedModelOption: ModelOption | null;
...@@ -339,6 +348,26 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -339,6 +348,26 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
339 ? editingPendingProject348 ? editingPendingProject
340 : (projectsForEnvironment[0] ?? null));349 : (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
342 // Only offer machines that actually host the currently selected repository, so371 // Only offer machines that actually host the currently selected repository, so
343 // switching computers moves the same repo across machines instead of jumping to372 // switching computers moves the same repo across machines instead of jumping to
344 // whatever unrelated project happens to be first on the other machine. Repository373 // whatever unrelated project happens to be first on the other machine. Repository
...@@ -350,6 +379,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -350,6 +379,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
350 const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;379 const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
351 const selectedProjectTitle = selectedProject?.title ?? null;380 const selectedProjectTitle = selectedProject?.title ?? null;
352 const environments = useMemo(() => {381 const environments = useMemo(() => {
382 if (isScratchDraft) {
383 return scratchEnvironments.map((environment) => ({
384 environmentId: environment.environmentId,
385 environmentLabel: environment.environmentLabel,
386 }));
387 }
353 const seen = new Set<EnvironmentId>();388 const seen = new Set<EnvironmentId>();
354 const result: Array<{389 const result: Array<{
355 readonly environmentId: EnvironmentId;390 readonly environmentId: EnvironmentId;
...@@ -387,16 +422,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -387,16 +422,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
387 }422 }
388 return result;423 return result;
389 }, [424 }, [
425 isScratchDraft,
390 projects,426 projects,
391 savedConnectionsById,427 savedConnectionsById,
428 scratchEnvironments,
392 selectedRepositoryKey,429 selectedRepositoryKey,
393 selectedWorkspaceBasename,430 selectedWorkspaceBasename,
394 selectedProjectTitle,431 selectedProjectTitle,
395 ]);432 ]);
396433
397 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
398 selectedProject?.environmentId ?? null,
399 );
400 // While a queued pending task is being edited its draft lives under a key434 // While a queued pending task is being edited its draft lives under a key
401 // scoped to the queued message, so new-task drafts stay intact.435 // scoped to the queued message, so new-task drafts stay intact.
402 const selectedProjectDraftKey = editingPendingTask436 const selectedProjectDraftKey = editingPendingTask
...@@ -457,10 +491,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -457,10 +491,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
457 );491 );
458 // A thread without a project runs in a plain folder, so worktree mode492 // A thread without a project runs in a plain folder, so worktree mode
459 // would leave it unsendable: it is always local and offers no choice.493 // would leave it unsendable: it is always local and offers no choice.
460 const canChooseWorkspace = !(494 const canChooseWorkspace = !isScratchDraft;
461 selectedProject !== null &&
462 isScratchProject(selectedProject, selectedEnvironmentServerConfig?.scratchWorkspaceRoot)
463 );
464 const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace495 const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace
465 ? projectSettings.settings.defaultThreadEnvMode496 ? projectSettings.settings.defaultThreadEnvMode
466 : "local";497 : "local";
...@@ -1192,6 +1223,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1192,6 +1223,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1192 expandedProvider,1223 expandedProvider,
1193 environments,1224 environments,
1194 selectedProject,1225 selectedProject,
1226 isScratchDraft,
1195 modelOptions,1227 modelOptions,
1196 selectedModel,1228 selectedModel,
1197 selectedModelOption,1229 selectedModelOption,
...@@ -1241,6 +1273,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1241,6 +1273,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1241 filteredBranches,1273 filteredBranches,
1242 finishEditingPendingTask,1274 finishEditingPendingTask,
1243 interactionMode,1275 interactionMode,
1276 isScratchDraft,
1244 planModeEnabled,1277 planModeEnabled,
1245 loadBranches,1278 loadBranches,
1246 loadMoreBranches,1279 loadMoreBranches,