Skip to content

Commit e109dc8

Browse files
refactor(mobile): each new-task draft route keeps its own flow state (#25812)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a86c05f commit e109dc8

8 files changed

Lines changed: 455 additions & 121 deletions

File tree

‎apps/mobile/src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
linkToRootPath,
8484
rootNavigationRef,
8585
} from "./lib/rootNavigation";
86+
import { pruneNewTaskFlowStates } from "./features/threads/new-task-flow-state";
8687
import { pruneNewTaskBranchCheckouts } from "./features/threads/NewTaskDraftRouteScreen";
8788

8889
import "../global.css";
@@ -147,6 +148,7 @@ function handleNavigationStateChange(state: NavigationState | undefined): void {
147148
if (!state) return;
148149
setAgentNotificationPathname(workspaceLocationFromState(state).pathname);
149150
recordAppShortcutNavigationState(state);
151+
pruneNewTaskFlowStates(state);
150152
pruneNewTaskBranchCheckouts(state);
151153
pruneIncomingShareImports(state);
152154
}

‎apps/mobile/src/Stack.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
NewTaskThreadSettingsRouteScreen,
9090
} from "./features/threads/ThreadSettingsSheet";
9191
import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
92+
import { newTaskFlowKey } from "./features/threads/new-task-flow-state";
9293
import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
9394
import { SettingsNavigationRouteScreen } from "./features/settings/SettingsNavigationRouteScreen";
9495
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
@@ -503,7 +504,12 @@ function RootStackLayout(props: {
503504
.slice(0, props.state.index + 1)
504505
.filter((route) => !isWorkspaceOverlayRoute(route.name)),
505506
);
506-
const draftRoute = props.state.routes.findLast((route) => route.name === "Thread");
507+
// The flow follows the draft route even while a pushed Thread covers it, so
508+
// its state lives exactly as long as the route.
509+
const draftRoute = props.state.routes.findLast(isDraftThreadRoute);
510+
const draftRouteOnTop =
511+
draftRoute !== undefined &&
512+
props.state.routes.findLast((route) => route.name === "Thread") === draftRoute;
507513
const draftParams = (
508514
draftRoute?.params as { draft?: { draftId?: string | string[] } } | undefined
509515
)?.draft;
@@ -512,7 +518,11 @@ function RootStackLayout(props: {
512518
: draftParams?.draftId;
513519

514520
return (
515-
<NewTaskFlowProvider active={isDraftThreadRoute(draftRoute)} routeDraftId={routeDraftId}>
521+
<NewTaskFlowProvider
522+
active={draftRouteOnTop}
523+
flowKey={draftRoute === undefined ? undefined : newTaskFlowKey(draftRoute)}
524+
routeDraftId={routeDraftId}
525+
>
516526
<HardwareKeyboardCommandProvider pathname={pathname}>
517527
<ShowcaseCaptureCoordinator pathname={pathname} />
518528
<SettingsEnvironmentFilterProvider open={settingsOpen}>

‎apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useNewTaskFlow } from "./new-task-flow-provider";
21
import * as Dialog from "../../components/ui/Dialog";
32
import {
43
CommonActions,
@@ -7,7 +6,7 @@ import {
76
usePreventRemoveContext,
87
type StaticScreenProps,
98
} from "@react-navigation/native";
10-
import { useEffect, useLayoutEffect, useState } from "react";
9+
import { useEffect, useState } from "react";
1110
import { View } from "react-native";
1211
import { useAtomValue } from "@effect/atom-react";
1312
import {
@@ -91,13 +90,6 @@ const idleBranchCheckoutAtom = Atom.make(AsyncResult.initial<BranchCheckoutResul
9190
export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {
9291
const currentRoute = useRoute();
9392
const params = route.params ?? {};
94-
const { reset } = useNewTaskFlow();
95-
const [initialized, setInitialized] = useState(false);
96-
useLayoutEffect(() => {
97-
reset();
98-
// oxlint-disable-next-line react/set-state-in-effect -- Draft reset runs once before first paint; initialized gates the screen until then.
99-
setInitialized(true);
100-
}, [reset]);
10193
const draftId = Array.isArray(params.draftId) ? params.draftId[0] : params.draftId;
10294
const projects = useProjects();
10395
const { state: catalogState } = useWorkspaceState();
@@ -201,7 +193,6 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
201193
};
202194
const preparingBranch = checkoutPending || (needsPreparation && result?._tag !== "Success");
203195

204-
if (!initialized) return null;
205196
return (
206197
<>
207198
{preparingBranch ? (

‎apps/mobile/src/features/threads/NewTaskDraftScreen.tsx‎

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ import {
6161
getComposerDraftSnapshot,
6262
updateComposerDraftSettings,
6363
scheduleUnusedComposerAttachmentCleanup,
64-
waitForComposerDraftsLoaded,
6564
} from "../../state/use-composer-drafts";
6665
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
6766
import { useProjectClone } from "../../state/projectClones";
@@ -223,10 +222,6 @@ export function NewTaskDraftScreen(props: {
223222
}
224223
};
225224
const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
226-
const selectedProjectRef = useRef(selectedProject);
227-
useEffect(() => {
228-
selectedProjectRef.current = selectedProject;
229-
}, [selectedProject]);
230225
const removeClonedProject = async () => {
231226
if (!selectedProject) return;
232227
const removed = selectedProject;
@@ -242,8 +237,7 @@ export function NewTaskDraftScreen(props: {
242237
);
243238
return;
244239
}
245-
const current = selectedProjectRef.current;
246-
if (current?.id === removed.id && current.environmentId === removed.environmentId) {
240+
if (flow.getSelectedProjectKey() === scopedProjectKey(removed.environmentId, removed.id)) {
247241
navigation.dispatch(StackActions.replace("Home"));
248242
}
249243
};
@@ -280,8 +274,12 @@ export function NewTaskDraftScreen(props: {
280274
project.id === props.initialProjectRef?.projectId,
281275
),
282276
);
277+
// A route naming a draft that is gone, or whose project is gone, also
278+
// returns to the picker.
279+
const isRouteDraftMissing =
280+
props.draftId !== undefined && flow.missingRouteDraftId === props.draftId;
283281
const isProjectPickerReturnActive =
284-
isReturningToProjectPicker && !requestedInitialProjectAvailable;
282+
(isReturningToProjectPicker && !requestedInitialProjectAvailable) || isRouteDraftMissing;
285283
const isIncomingShareAwaitingServerConfig = Boolean(
286284
incomingShare?.attachments.some((attachment) => attachment.type === "file") &&
287285
selectedEnvironmentServerConfig === null,
@@ -373,12 +371,11 @@ export function NewTaskDraftScreen(props: {
373371
isIncomingShareUnavailable;
374372
const appliedInitialProjectKeyRef = useRef<string | null>(null);
375373
useEffect(() => {
376-
if (!isReturningToProjectPicker) {
377-
return;
378-
}
379-
if (requestedInitialProjectAvailable) {
374+
if (isReturningToProjectPicker && requestedInitialProjectAvailable) {
380375
// oxlint-disable-next-line react/set-state-in-effect -- Ends the return-to-picker flow once the requested project arrives from the catalog.
381376
setIsReturningToProjectPicker(false);
377+
}
378+
if (!isProjectPickerReturnActive) {
382379
return;
383380
}
384381
// Wait for usePreventRemove to disable before replacing, or the transfer guard swallows the fallback.
@@ -389,41 +386,12 @@ export function NewTaskDraftScreen(props: {
389386
});
390387
return () => cancelAnimationFrame(frame);
391388
}, [
389+
isProjectPickerReturnActive,
392390
isReturningToProjectPicker,
393391
navigation,
394392
props.incomingShareId,
395393
requestedInitialProjectAvailable,
396394
]);
397-
const { openDraft } = flow;
398-
// Cold launches load drafts and projects separately; wait for hydration and retry while shells load.
399-
const attemptedDraftIdRef = useRef<string | null>(null);
400-
useEffect(() => {
401-
if (!props.draftId) {
402-
return;
403-
}
404-
const draftId = props.draftId;
405-
if (attemptedDraftIdRef.current === draftId) {
406-
return;
407-
}
408-
let cancelled = false;
409-
void waitForComposerDraftsLoaded().then(() => {
410-
if (cancelled || attemptedDraftIdRef.current === draftId) {
411-
return;
412-
}
413-
if (openDraft(draftId)) {
414-
attemptedDraftIdRef.current = draftId;
415-
return;
416-
}
417-
if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) {
418-
return;
419-
}
420-
attemptedDraftIdRef.current = draftId;
421-
navigation.dispatch(CommonActions.navigate("NewTaskProject"));
422-
});
423-
return () => {
424-
cancelled = true;
425-
};
426-
}, [navigation, openDraft, projects, props.draftId]);
427395

428396
const lastInitialProjectRefRef = useRef(props.initialProjectRef);
429397
const initialPickerRef = useRef<"open" | "dismissed" | null>(null);

0 commit comments

Comments
 (0)