From 74420b47654621f8351565835a45fca776461688 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:17:04 -0700 Subject: [PATCH 1/4] refactor(mobile): each new-task draft route keeps its own flow state The new-task flow (selected project, draft, submitting, expanded provider) was one set of useState in the provider shared by every draft route. Each draft route reset it in a layout effect on mount and gated its first render on that, and a draft opened by id was bound by an effect that awaited draft hydration and re-ran on every project list change. Flow state now lives in a module atom keyed by the draft route (route key + draft identity) and is pruned from the navigation state change handler when the route leaves the stack. A new route starts fresh by key, so the reset effect and its gate are gone. A route's draftId is resolved in render from the hydrated drafts (new composerDraftsLoadedAtom) and loaded projects; a draft or project that is gone sends the route back to the project picker. Effects: NewTaskDraftRouteScreen 1 -> 0 (reset layout effect), NewTaskDraftScreen 2 removed (selectedProjectRef sync, draft-open retry effect). The provider keeps its draft-creation effect: the draft key must exist before the composer mounts. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 2 + apps/mobile/src/Stack.tsx | 14 +- .../threads/NewTaskDraftRouteScreen.tsx | 11 +- .../features/threads/NewTaskDraftScreen.tsx | 47 +----- .../threads/new-task-flow-provider.tsx | 156 +++++++++++------- .../threads/new-task-flow-state.test.ts | 103 ++++++++++++ .../features/threads/new-task-flow-state.ts | 108 ++++++++++++ apps/mobile/src/state/use-composer-drafts.ts | 13 ++ 8 files changed, 341 insertions(+), 113 deletions(-) create mode 100644 apps/mobile/src/features/threads/new-task-flow-state.test.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 1443544d9469..a15f0c6c1b7f 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -75,6 +75,7 @@ import { linkToRootPath, rootNavigationRef, } from "./lib/rootNavigation"; +import { pruneNewTaskFlowStates } from "./features/threads/new-task-flow-state"; import { pruneNewTaskBranchCheckouts } from "./features/threads/NewTaskDraftRouteScreen"; import "../global.css"; @@ -133,6 +134,7 @@ function handleNavigationStateChange(state: NavigationState | undefined): void { if (!state) return; setAgentNotificationPathname(workspaceLocationFromState(state).pathname); recordAppShortcutNavigationState(state); + pruneNewTaskFlowStates(state); pruneNewTaskBranchCheckouts(state); pruneIncomingShareImports(state); } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 396759a5f04b..3d8516835021 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -89,6 +89,7 @@ import { NewTaskThreadSettingsRouteScreen, } from "./features/threads/ThreadSettingsSheet"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; +import { newTaskFlowKey } from "./features/threads/new-task-flow-state"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsNavigationRouteScreen } from "./features/settings/SettingsNavigationRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; @@ -500,7 +501,12 @@ function RootStackLayout(props: { .slice(0, props.state.index + 1) .filter((route) => !isWorkspaceOverlayRoute(route.name)), ); - const draftRoute = props.state.routes.findLast((route) => route.name === "Thread"); + // The flow follows the draft route even while a pushed Thread covers it, so + // its state lives exactly as long as the route. + const draftRoute = props.state.routes.findLast(isDraftThreadRoute); + const draftRouteOnTop = + draftRoute !== undefined && + props.state.routes.findLast((route) => route.name === "Thread") === draftRoute; const draftParams = ( draftRoute?.params as { draft?: { draftId?: string | string[] } } | undefined )?.draft; @@ -509,7 +515,11 @@ function RootStackLayout(props: { : draftParams?.draftId; return ( - + diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 2bdcb47ec48b..ed69a1f5a099 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -1,4 +1,3 @@ -import { useNewTaskFlow } from "./new-task-flow-provider"; import * as Dialog from "../../components/ui/Dialog"; import { CommonActions, @@ -7,7 +6,7 @@ import { usePreventRemoveContext, type StaticScreenProps, } from "@react-navigation/native"; -import { useEffect, useLayoutEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { View } from "react-native"; import { useAtomValue } from "@effect/atom-react"; import { @@ -91,13 +90,6 @@ const idleBranchCheckoutAtom = Atom.make(AsyncResult.initial) { const currentRoute = useRoute(); const params = route.params ?? {}; - const { reset } = useNewTaskFlow(); - const [initialized, setInitialized] = useState(false); - useLayoutEffect(() => { - reset(); - // oxlint-disable-next-line react/set-state-in-effect -- Draft reset runs once before first paint; initialized gates the screen until then. - setInitialized(true); - }, [reset]); const draftId = Array.isArray(params.draftId) ? params.draftId[0] : params.draftId; const projects = useProjects(); const { state: catalogState } = useWorkspaceState(); @@ -201,7 +193,6 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps {preparingBranch ? ( diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 007cfb44993d..716e79b4996e 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -61,7 +61,6 @@ import { getComposerDraftSnapshot, updateComposerDraftSettings, scheduleUnusedComposerAttachmentCleanup, - waitForComposerDraftsLoaded, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; import { useProjectClone } from "../../state/projectClones"; @@ -223,10 +222,6 @@ export function NewTaskDraftScreen(props: { } }; const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); - const selectedProjectRef = useRef(selectedProject); - useEffect(() => { - selectedProjectRef.current = selectedProject; - }, [selectedProject]); const removeClonedProject = async () => { if (!selectedProject) return; const removed = selectedProject; @@ -242,8 +237,7 @@ export function NewTaskDraftScreen(props: { ); return; } - const current = selectedProjectRef.current; - if (current?.id === removed.id && current.environmentId === removed.environmentId) { + if (flow.getSelectedProjectKey() === scopedProjectKey(removed.environmentId, removed.id)) { navigation.dispatch(StackActions.replace("Home")); } }; @@ -280,8 +274,10 @@ export function NewTaskDraftScreen(props: { project.id === props.initialProjectRef?.projectId, ), ); + // A route naming a draft that is gone, or whose project is gone, also + // returns to the picker. const isProjectPickerReturnActive = - isReturningToProjectPicker && !requestedInitialProjectAvailable; + (isReturningToProjectPicker && !requestedInitialProjectAvailable) || flow.routeTargetMissing; const isIncomingShareAwaitingServerConfig = Boolean( incomingShare?.attachments.some((attachment) => attachment.type === "file") && selectedEnvironmentServerConfig === null, @@ -373,10 +369,10 @@ export function NewTaskDraftScreen(props: { isIncomingShareUnavailable; const appliedInitialProjectKeyRef = useRef(null); useEffect(() => { - if (!isReturningToProjectPicker) { + if (!isProjectPickerReturnActive) { return; } - if (requestedInitialProjectAvailable) { + if (isReturningToProjectPicker && requestedInitialProjectAvailable) { // oxlint-disable-next-line react/set-state-in-effect -- Ends the return-to-picker flow once the requested project arrives from the catalog. setIsReturningToProjectPicker(false); return; @@ -389,41 +385,12 @@ export function NewTaskDraftScreen(props: { }); return () => cancelAnimationFrame(frame); }, [ + isProjectPickerReturnActive, isReturningToProjectPicker, navigation, props.incomingShareId, requestedInitialProjectAvailable, ]); - const { openDraft } = flow; - // Cold launches load drafts and projects separately; wait for hydration and retry while shells load. - const attemptedDraftIdRef = useRef(null); - useEffect(() => { - if (!props.draftId) { - return; - } - const draftId = props.draftId; - if (attemptedDraftIdRef.current === draftId) { - return; - } - let cancelled = false; - void waitForComposerDraftsLoaded().then(() => { - if (cancelled || attemptedDraftIdRef.current === draftId) { - return; - } - if (openDraft(draftId)) { - attemptedDraftIdRef.current = draftId; - return; - } - if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) { - return; - } - attemptedDraftIdRef.current = draftId; - navigation.dispatch(CommonActions.navigate("NewTaskProject")); - }); - return () => { - cancelled = true; - }; - }, [navigation, openDraft, projects, props.draftId]); const lastInitialProjectRefRef = useRef(props.initialProjectRef); const initialPickerRef = useRef<"open" | "dismissed" | null>(null); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 6f049bc54184..d45dcabb3677 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,6 +1,6 @@ import { ActiveNewTaskContext } from "../../state/active-new-task"; import * as Dialog from "../../components/ui/Dialog"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import type { EnvironmentId, @@ -33,6 +33,7 @@ import { resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; +import { useAtomValue } from "@effect/atom-react"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; @@ -41,7 +42,7 @@ import { appendComposerDraftAttachments, type ComposerDraftInsertion, type ComposerDraftWorkspaceSelection, - composerDraftsAtom, + composerDraftsLoadedAtom, createNewTaskDraft, getComposerDraftSnapshot, isNewTaskDraftKey, @@ -92,6 +93,15 @@ import { import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; +import { + INACTIVE_FLOW_KEY, + type NewTaskFlowState, + newTaskFlowStateAtom, + readNewTaskFlowState, + resolveRouteDraft, + type RouteDraftResolution, + updateNewTaskFlowState, +} from "./new-task-flow-state"; type WorkspaceMode = "local" | "worktree"; @@ -153,10 +163,12 @@ type NewTaskFlowContextValue = { readonly selectedModelOption: ModelOption | null; readonly selectedProviderStatus: ServerProvider | null; readonly providerGroups: ReadonlyArray; - readonly reset: () => void; readonly setProject: (project: EnvironmentProject) => void; /** Returns false if the existing draft is gone, allowing fallback to a fresh draft. */ - readonly openDraft: (draftKey: string) => boolean; + /** The flow's project, read when a continuation resumes after an await. */ + readonly getSelectedProjectKey: () => string | null; + /** The route names a draft that is gone, or whose project is gone. */ + readonly routeTargetMissing: boolean; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly switchEnvironment: (environmentId: EnvironmentId) => Promise; readonly switchingToEnvironmentId: EnvironmentId | null; @@ -197,6 +209,8 @@ const NewTaskFlowContext = React.createContext(n export function NewTaskFlowProvider( props: React.PropsWithChildren<{ active?: boolean; + /** Identifies the draft route this flow serves; a new key starts a fresh flow. */ + flowKey?: string; routeDraftId?: string; }>, ) { @@ -217,27 +231,57 @@ export function NewTaskFlowProvider( projectSortOrder: "updated_at", }); - const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState( - null, + const flowKey = props.flowKey ?? INACTIVE_FLOW_KEY; + const storedFlowState = useAtomValue(newTaskFlowStateAtom(flowKey)); + const draftsLoaded = useAtomValue(composerDraftsLoadedAtom); + // A draft route opened from a list row, a deep link, or restored state binds + // its draft here until the flow's first write stores the binding. + const routeDraft = useComposerDraft( + storedFlowState.draftKey === null ? (props.routeDraftId ?? null) : null, ); + const resolveRoute = ( + flow: NewTaskFlowState, + loaded: boolean, + stamp: Parameters[0]["stamp"], + ): RouteDraftResolution => + resolveRouteDraft({ + flow, + routeDraftId: props.routeDraftId, + draftsLoaded: loaded, + stamp, + projects, + }); + const routeDraftResolution = resolveRoute(storedFlowState, draftsLoaded, routeDraft.project); + const flowState = + routeDraftResolution.kind === "open" ? routeDraftResolution.flow : storedFlowState; + const { + environmentId: selectedEnvironmentIdOverride, + projectKey: selectedProjectKey, + draftKey: activeDraftKey, + submitting, + expandedProvider, + } = flowState; + // Continuations read the flow at use time, with the route's draft applied. + const readFlow = (): NewTaskFlowState => { + const stored = readNewTaskFlowState(flowKey); + const resolution = resolveRoute( + stored, + appAtomRegistry.get(composerDraftsLoadedAtom), + props.routeDraftId === undefined + ? undefined + : getComposerDraftSnapshot(props.routeDraftId).project, + ); + return resolution.kind === "open" ? resolution.flow : stored; + }; + // Every flow write stores the route's draft binding along with it, so the + // binding outlives the draft's project stamp (submit clears it). + const updateFlow = (patch: Partial) => + updateNewTaskFlowState(flowKey, { ...readFlow(), ...patch }); const selectedEnvironmentId = selectedEnvironmentIdOverride !== null && projects.some((project) => project.environmentId === selectedEnvironmentIdOverride) ? selectedEnvironmentIdOverride : (projects[0]?.environmentId ?? null); - const [selectedProjectKey, setSelectedProjectKey] = useState(null); - const [activeDraftKey, setActiveDraftKey] = useState(null); - const [submitting, setSubmitting] = useState(false); - const [expandedProvider, setExpandedProvider] = useState(null); - - const reset = useCallback(() => { - setSelectedEnvironmentId(null); - setSelectedProjectKey(null); - setActiveDraftKey(null); - setSubmitting(false); - setExpandedProvider(null); - }, []); - const projectsForEnvironment = pipe( projects, Arr.filter((project) => project.environmentId === selectedEnvironmentId), @@ -318,17 +362,23 @@ export function NewTaskFlowProvider( const selectedProjectDraftKey = selectedProject ? activeDraftKey : null; useEffect(() => { - if (props.active === false || activeDraftKey !== null || selectedProject === null) { + // A route that names a draft binds that draft, or returns to the picker. + if ( + props.active === false || + props.routeDraftId !== undefined || + activeDraftKey !== null || + selectedProject === null + ) { return; } - // oxlint-disable-next-line react/set-state-in-effect -- Creates the persistent draft once the project selection is ready; the key must exist before the composer mounts. - setActiveDraftKey( - createNewTaskDraft({ + // Creates the persistent draft once the project selection is ready; the key must exist before the composer mounts. + updateNewTaskFlowState(flowKey, { + draftKey: createNewTaskDraft({ environmentId: selectedProject.environmentId, projectId: selectedProject.id, }), - ); - }, [activeDraftKey, selectedProject, props.active]); + }); + }, [activeDraftKey, flowKey, selectedProject, props.active, props.routeDraftId]); const activePendingKey = props.active === false ? null @@ -528,38 +578,22 @@ export function NewTaskFlowProvider( replaceComposerDraftAttachments(selectedProjectDraftKey, []); }; - const carryDraftContentTo = (project: EnvironmentProject) => { + const carryDraftContentTo = (project: EnvironmentProject): string => { const target = { environmentId: project.environmentId, projectId: project.id }; - if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) { - retargetNewTaskDraft(activeDraftKey, target); - } else { - setActiveDraftKey(createNewTaskDraft(target)); + const current = readFlow().draftKey; + if (current !== null && isNewTaskDraftKey(current)) { + retargetNewTaskDraft(current, target); + return current; } + return createNewTaskDraft(target); }; const setProject = (project: EnvironmentProject) => { - carryDraftContentTo(project); - setSelectedEnvironmentId(project.environmentId); - setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id)); - }; - - const openDraft = (draftKey: string): boolean => { - const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; - const stamp = draft?.project; - if (!isNewTaskDraftKey(draftKey) || !stamp) { - return false; - } - // Require the stamped project to load; selectedProject otherwise falls back and retargets the draft. - const projectLoaded = projects.some( - (project) => project.environmentId === stamp.environmentId && project.id === stamp.projectId, - ); - if (!projectLoaded) { - return false; - } - setActiveDraftKey(draftKey); - setSelectedEnvironmentId(stamp.environmentId); - setSelectedProjectKey(scopedProjectKey(stamp.environmentId, stamp.projectId)); - return true; + updateFlow({ + draftKey: carryDraftContentTo(project), + environmentId: project.environmentId, + projectKey: scopedProjectKey(project.environmentId, project.id), + }); }; const selectEnvironment = (environmentId: EnvironmentId) => { @@ -567,11 +601,11 @@ export function NewTaskFlowProvider( projects.filter((project) => project.environmentId === environmentId), selectedProject, ); - if (match) { - carryDraftContentTo(match); - } - setSelectedEnvironmentId(environmentId); - setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); + updateFlow({ + draftKey: match ? carryDraftContentTo(match) : readFlow().draftKey, + environmentId, + projectKey: match ? scopedProjectKey(match.environmentId, match.id) : null, + }); }; const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); @@ -797,9 +831,9 @@ export function NewTaskFlowProvider( selectedModelOption, selectedProviderStatus, providerGroups, - reset, setProject, - openDraft, + getSelectedProjectKey: () => readFlow().projectKey, + routeTargetMissing: routeDraftResolution.kind === "missing", selectEnvironment, switchEnvironment, switchingToEnvironmentId, @@ -813,12 +847,12 @@ export function NewTaskFlowProvider( appendAttachments, removeAttachment, clearAttachments, - setSubmitting, + setSubmitting: (value: boolean) => updateFlow({ submitting: value }), loadBranches, setRuntimeMode, setInteractionMode, setSelectedModelOptions, - setExpandedProvider, + setExpandedProvider: (value: string | null) => updateFlow({ expandedProvider: value }), } satisfies NewTaskFlowContextValue; return ( diff --git a/apps/mobile/src/features/threads/new-task-flow-state.test.ts b/apps/mobile/src/features/threads/new-task-flow-state.test.ts new file mode 100644 index 000000000000..68697cd20838 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-flow-state.test.ts @@ -0,0 +1,103 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { + INITIAL_NEW_TASK_FLOW_STATE, + newTaskFlowKey, + newTaskFlowStateAtom, + pruneNewTaskFlowStates, + readNewTaskFlowState, + resolveRouteDraft, + updateNewTaskFlowState, +} from "./new-task-flow-state"; + +function draftRoute(key: string, draftId: string) { + return { key, name: "Thread", params: { draft: { draftId } } }; +} + +describe("new task flow state", () => { + it("keeps a covered draft route's flow while another draft route is pushed above it", () => { + const routeA = draftRoute("route-a", "draft-a"); + const routeB = draftRoute("route-b", "draft-b"); + const flowA = newTaskFlowKey(routeA)!; + const flowB = newTaskFlowKey(routeB)!; + + // The provider subscribes to A, writes its selection, then moves to B. + const unmountA = appAtomRegistry.mount(newTaskFlowStateAtom(flowA)); + updateNewTaskFlowState(flowA, { projectKey: "env:project-a", draftKey: "draft-a" }); + unmountA(); + const unmountB = appAtomRegistry.mount(newTaskFlowStateAtom(flowB)); + pruneNewTaskFlowStates({ routes: [{ key: "home", name: "Home" }, routeA, routeB] }); + + expect(readNewTaskFlowState(flowB)).toBe(INITIAL_NEW_TASK_FLOW_STATE); + expect(appAtomRegistry.get(newTaskFlowStateAtom(flowA))).toMatchObject({ + projectKey: "env:project-a", + draftKey: "draft-a", + }); + + // Popping B returns to A with its state intact. + unmountB(); + pruneNewTaskFlowStates({ routes: [{ key: "home", name: "Home" }, routeA] }); + expect(readNewTaskFlowState(flowA).projectKey).toBe("env:project-a"); + }); + + it("drops a flow when its route is popped or promoted to a live thread", () => { + const routeA = draftRoute("route-a", "draft-a"); + const routeB = draftRoute("route-b", "draft-b"); + updateNewTaskFlowState(newTaskFlowKey(routeA)!, { projectKey: "env:project-a" }); + updateNewTaskFlowState(newTaskFlowKey(routeB)!, { projectKey: "env:project-b" }); + + pruneNewTaskFlowStates({ + routes: [{ key: "route-a", name: "Thread", params: { environmentId: "env", threadId: "t" } }], + }); + + expect(readNewTaskFlowState(newTaskFlowKey(routeA)!)).toBe(INITIAL_NEW_TASK_FLOW_STATE); + expect(readNewTaskFlowState(newTaskFlowKey(routeB)!)).toBe(INITIAL_NEW_TASK_FLOW_STATE); + }); +}); + +describe("resolveRouteDraft", () => { + const environmentId = EnvironmentId.make("env"); + const projectId = ProjectId.make("project"); + const stamp = { environmentId, projectId }; + const project = { environmentId, id: projectId }; + const base = { + flow: INITIAL_NEW_TASK_FLOW_STATE, + routeDraftId: "new-task:draft-1", + draftsLoaded: true, + stamp, + projects: [project], + }; + + it("binds the route's draft and its stamped project", () => { + expect(resolveRouteDraft(base)).toEqual({ + kind: "open", + flow: { + ...INITIAL_NEW_TASK_FLOW_STATE, + environmentId, + projectKey: "env:project", + draftKey: "new-task:draft-1", + }, + }); + }); + + it("leaves a flow that already bound a draft alone", () => { + const flow = { ...INITIAL_NEW_TASK_FLOW_STATE, draftKey: "new-task:other" }; + expect(resolveRouteDraft({ ...base, flow })).toEqual({ kind: "none" }); + }); + + it("waits for hydration, and for projects while none have loaded", () => { + expect(resolveRouteDraft({ ...base, draftsLoaded: false, stamp: undefined })).toEqual({ + kind: "pending", + }); + expect(resolveRouteDraft({ ...base, projects: [] })).toEqual({ kind: "pending" }); + }); + + it("reports a draft that is gone or whose project is gone", () => { + expect(resolveRouteDraft({ ...base, stamp: undefined })).toEqual({ kind: "missing" }); + expect( + resolveRouteDraft({ ...base, projects: [{ environmentId, id: ProjectId.make("other") }] }), + ).toEqual({ kind: "missing" }); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-flow-state.ts b/apps/mobile/src/features/threads/new-task-flow-state.ts index 37a04a0b4de3..40088ec71d91 100644 --- a/apps/mobile/src/features/threads/new-task-flow-state.ts +++ b/apps/mobile/src/features/threads/new-task-flow-state.ts @@ -1,9 +1,18 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { isNewTaskDraftKey } from "../../state/new-task-draft-key"; import { draftRouteIdentity, isDraftThreadRoute, type NewTaskDraftRouteParams, } from "./thread-route-state"; +/** The flow a provider serves while no draft route is on the stack. */ +export const INACTIVE_FLOW_KEY = "inactive"; + /** The flow key for a draft Thread route: its key plus the draft it opened. */ export function newTaskFlowKey(route: { readonly key: string; @@ -13,6 +22,93 @@ export function newTaskFlowKey(route: { return draft ? `${route.key}:${draftRouteIdentity(draft)}` : undefined; } +export interface NewTaskFlowState { + readonly environmentId: EnvironmentId | null; + readonly projectKey: string | null; + readonly draftKey: string | null; + readonly submitting: boolean; + readonly expandedProvider: string | null; +} + +export const INITIAL_NEW_TASK_FLOW_STATE: NewTaskFlowState = { + environmentId: null, + projectKey: null, + draftKey: null, + submitting: false, + expandedProvider: null, +}; + +// Every draft route on the stack keeps its flow, not just the one the provider +// currently serves: a draft Thread pushed above draft A must not drop A's +// selection. Entries live exactly as long as their route (see prune below). +const flowStatesAtom = Atom.make>>({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:new-task-flows"), +); + +export const newTaskFlowStateAtom = Atom.family((flowKey: string) => + Atom.make((get) => get(flowStatesAtom)[flowKey] ?? INITIAL_NEW_TASK_FLOW_STATE).pipe( + Atom.withLabel("mobile:new-task-flow"), + ), +); + +export function readNewTaskFlowState(flowKey: string): NewTaskFlowState { + return appAtomRegistry.get(flowStatesAtom)[flowKey] ?? INITIAL_NEW_TASK_FLOW_STATE; +} + +export function updateNewTaskFlowState(flowKey: string, patch: Partial): void { + const flows = appAtomRegistry.get(flowStatesAtom); + appAtomRegistry.set(flowStatesAtom, { + ...flows, + [flowKey]: { ...(flows[flowKey] ?? INITIAL_NEW_TASK_FLOW_STATE), ...patch }, + }); +} + +/** + * What a draft route's `draftId` means for a flow that has not bound a draft + * yet. Drafts hydrate from disk and projects arrive with the shell snapshot, + * so on a cold launch or deep link the answer can be "pending" for a while. + */ +export type RouteDraftResolution = + | { readonly kind: "none" } + | { readonly kind: "pending" } + | { readonly kind: "missing" } + | { readonly kind: "open"; readonly flow: NewTaskFlowState }; + +export function resolveRouteDraft(input: { + readonly flow: NewTaskFlowState; + readonly routeDraftId: string | undefined; + readonly draftsLoaded: boolean; + readonly stamp: + | { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } + | undefined; + readonly projects: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly id: ProjectId; + }>; +}): RouteDraftResolution { + const { routeDraftId, stamp } = input; + if (routeDraftId === undefined || input.flow.draftKey !== null) return { kind: "none" }; + if (!input.draftsLoaded) return { kind: "pending" }; + if (!isNewTaskDraftKey(routeDraftId) || stamp === undefined) return { kind: "missing" }; + // The stamped project must be loaded: the composer falls back to the + // environment's first project otherwise, and the draft would be sent + // somewhere the user never chose. + const loaded = input.projects.some( + (project) => project.environmentId === stamp.environmentId && project.id === stamp.projectId, + ); + if (!loaded) return input.projects.length === 0 ? { kind: "pending" } : { kind: "missing" }; + return { + kind: "open", + flow: { + ...input.flow, + environmentId: stamp.environmentId, + projectKey: scopedProjectKey(stamp.environmentId, stamp.projectId), + draftKey: routeDraftId, + }, + }; +} + interface NavigationRoutes { readonly routes: ReadonlyArray<{ readonly key: string; @@ -30,3 +126,15 @@ export function liveNewTaskFlowKeys(state: NavigationRoutes): ReadonlySet live.has(flowKey)); + if (kept.length === Object.keys(flows).length) return; + appAtomRegistry.set(flowStatesAtom, Object.fromEntries(kept)); +} diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index f81f39c3f429..e6adfa42584e 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -471,6 +471,17 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.withLabel("mobile:composer-drafts"), ); +const composerDraftsHydratedAtom = Atom.make(false).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:composer-drafts-hydrated"), +); + +/** Whether persisted drafts have merged in; reading it starts hydration. */ +export const composerDraftsLoadedAtom = Atom.make((get) => { + ensureComposerDraftsLoaded(); + return get(composerDraftsHydratedAtom); +}).pipe(Atom.withLabel("mobile:composer-drafts-loaded")); + export const stickyComposerModelSelectionAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-model-selection"), @@ -507,6 +518,7 @@ const persistenceQueue = new SerializedAsyncQueue(); export function resetComposerDraftsLoadState(): void { loadPromise = null; + appAtomRegistry.set(composerDraftsHydratedAtom, false); persistRetryNeeded = false; } @@ -1039,6 +1051,7 @@ export function ensureComposerDraftsLoaded(): void { ), }); } + appAtomRegistry.set(composerDraftsHydratedAtom, true); }); loadPromise = loading; void loading.catch((cause) => { From 1f49c486d35e0214ce4c653a8ffcc6233eaf6368 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:11:28 -0700 Subject: [PATCH 2/4] fix(mobile): draft routes return to the picker only for their own missing draft - The provider now names the route draft it found missing, and a draft screen returns to the picker only when that is its own draft. A covered draft route no longer reads the topmost route's missing state. - The picker-return effect again clears the return flag once the requested project arrives, as on main. - composerDraftsLoadedAtom is a plain read of hydration state. Hydration starts at app start with the attachment upload worker, and a failed load is retried by the next ensureComposerDraftsLoaded caller (the draft route's useComposerDraft mount, draft edits, outbox delivery). Co-Authored-By: Claude Opus 5.5 --- .../features/threads/NewTaskDraftScreen.tsx | 9 +++++---- .../threads/new-task-flow-provider.tsx | 12 ++++++++---- apps/mobile/src/state/use-composer-drafts.ts | 19 +++++++++---------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 716e79b4996e..d5b5ff3c1370 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -276,8 +276,10 @@ export function NewTaskDraftScreen(props: { ); // A route naming a draft that is gone, or whose project is gone, also // returns to the picker. + const isRouteDraftMissing = + props.draftId !== undefined && flow.missingRouteDraftId === props.draftId; const isProjectPickerReturnActive = - (isReturningToProjectPicker && !requestedInitialProjectAvailable) || flow.routeTargetMissing; + (isReturningToProjectPicker && !requestedInitialProjectAvailable) || isRouteDraftMissing; const isIncomingShareAwaitingServerConfig = Boolean( incomingShare?.attachments.some((attachment) => attachment.type === "file") && selectedEnvironmentServerConfig === null, @@ -369,12 +371,11 @@ export function NewTaskDraftScreen(props: { isIncomingShareUnavailable; const appliedInitialProjectKeyRef = useRef(null); useEffect(() => { - if (!isProjectPickerReturnActive) { - return; - } if (isReturningToProjectPicker && requestedInitialProjectAvailable) { // oxlint-disable-next-line react/set-state-in-effect -- Ends the return-to-picker flow once the requested project arrives from the catalog. setIsReturningToProjectPicker(false); + } + if (!isProjectPickerReturnActive) { return; } // Wait for usePreventRemove to disable before replacing, or the transfer guard swallows the fallback. diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index d45dcabb3677..2e71c93a0990 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -164,11 +164,14 @@ type NewTaskFlowContextValue = { readonly selectedProviderStatus: ServerProvider | null; readonly providerGroups: ReadonlyArray; readonly setProject: (project: EnvironmentProject) => void; - /** Returns false if the existing draft is gone, allowing fallback to a fresh draft. */ /** The flow's project, read when a continuation resumes after an await. */ readonly getSelectedProjectKey: () => string | null; - /** The route names a draft that is gone, or whose project is gone. */ - readonly routeTargetMissing: boolean; + /** + * The draft id of the route this flow serves when that draft, or its + * project, is gone. Only the topmost draft route is served, so a screen + * compares this with its own draft id. + */ + readonly missingRouteDraftId: string | null; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly switchEnvironment: (environmentId: EnvironmentId) => Promise; readonly switchingToEnvironmentId: EnvironmentId | null; @@ -833,7 +836,8 @@ export function NewTaskFlowProvider( providerGroups, setProject, getSelectedProjectKey: () => readFlow().projectKey, - routeTargetMissing: routeDraftResolution.kind === "missing", + missingRouteDraftId: + routeDraftResolution.kind === "missing" ? (props.routeDraftId ?? null) : null, selectEnvironment, switchEnvironment, switchingToEnvironmentId, diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index e6adfa42584e..70c9503f34c2 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -471,17 +471,16 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.withLabel("mobile:composer-drafts"), ); -const composerDraftsHydratedAtom = Atom.make(false).pipe( +/** + * Whether persisted drafts have merged in. Hydration starts with the app's + * attachment upload worker; a failed load stays false until the next + * `ensureComposerDraftsLoaded` caller retries it. + */ +export const composerDraftsLoadedAtom = Atom.make(false).pipe( Atom.keepAlive, - Atom.withLabel("mobile:composer-drafts-hydrated"), + Atom.withLabel("mobile:composer-drafts-loaded"), ); -/** Whether persisted drafts have merged in; reading it starts hydration. */ -export const composerDraftsLoadedAtom = Atom.make((get) => { - ensureComposerDraftsLoaded(); - return get(composerDraftsHydratedAtom); -}).pipe(Atom.withLabel("mobile:composer-drafts-loaded")); - export const stickyComposerModelSelectionAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-model-selection"), @@ -518,7 +517,7 @@ const persistenceQueue = new SerializedAsyncQueue(); export function resetComposerDraftsLoadState(): void { loadPromise = null; - appAtomRegistry.set(composerDraftsHydratedAtom, false); + appAtomRegistry.set(composerDraftsLoadedAtom, false); persistRetryNeeded = false; } @@ -1051,7 +1050,7 @@ export function ensureComposerDraftsLoaded(): void { ), }); } - appAtomRegistry.set(composerDraftsHydratedAtom, true); + appAtomRegistry.set(composerDraftsLoadedAtom, true); }); loadPromise = loading; void loading.catch((cause) => { From 722ce0d5dd5c33dd213238a7e06ed2d561a310f8 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:37:30 -0700 Subject: [PATCH 3/4] fix(mobile): a draft stamped to a slower environment waits for that environment resolveRouteDraft treated any loaded project as readiness, so on a cold launch a draft stamped to environment B was reported missing as soon as environment A's projects arrived, and the route was replaced with the picker. It now stays pending while the stamped environment is still connecting or synchronizing its shell, and reports missing only once that environment has settled without the project. Co-Authored-By: Claude Opus 5.5 --- .../threads/new-task-flow-provider.tsx | 20 +++++++ .../threads/new-task-flow-state.test.ts | 55 ++++++++++++++++++- .../features/threads/new-task-flow-state.ts | 29 +++++++++- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 2e71c93a0990..2fa848976845 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -100,8 +100,11 @@ import { readNewTaskFlowState, resolveRouteDraft, type RouteDraftResolution, + stampedEnvironmentProjectsLoading, updateNewTaskFlowState, } from "./new-task-flow-state"; +import { useEnvironmentShellReadiness } from "../../state/shell"; +import { useConnectionsReady, useWorkspaceEnvironments } from "../../state/workspace"; type WorkspaceMode = "local" | "worktree"; @@ -242,6 +245,22 @@ export function NewTaskFlowProvider( const routeDraft = useComposerDraft( storedFlowState.draftKey === null ? (props.routeDraftId ?? null) : null, ); + // Readiness of the environment the route's draft is stamped to; another + // environment's projects loading first says nothing about it. + const stampedEnvironmentId = routeDraft.project?.environmentId ?? null; + const connectionsReady = useConnectionsReady(); + const stampedEnvironmentConnection = + useWorkspaceEnvironments().find( + (environment) => environment.environmentId === stampedEnvironmentId, + )?.connectionState ?? null; + const stampedEnvironmentShell = useEnvironmentShellReadiness(stampedEnvironmentId); + const stampedEnvironmentLoading = stampedEnvironmentProjectsLoading({ + connectionsReady, + connectionState: stampedEnvironmentConnection, + shellCacheRead: stampedEnvironmentShell.cacheRead, + shellStatus: stampedEnvironmentShell.status, + shellHasError: stampedEnvironmentShell.hasError, + }); const resolveRoute = ( flow: NewTaskFlowState, loaded: boolean, @@ -253,6 +272,7 @@ export function NewTaskFlowProvider( draftsLoaded: loaded, stamp, projects, + stampedEnvironmentLoading, }); const routeDraftResolution = resolveRoute(storedFlowState, draftsLoaded, routeDraft.project); const flowState = diff --git a/apps/mobile/src/features/threads/new-task-flow-state.test.ts b/apps/mobile/src/features/threads/new-task-flow-state.test.ts index 68697cd20838..73d2a2af4cb3 100644 --- a/apps/mobile/src/features/threads/new-task-flow-state.test.ts +++ b/apps/mobile/src/features/threads/new-task-flow-state.test.ts @@ -9,6 +9,7 @@ import { pruneNewTaskFlowStates, readNewTaskFlowState, resolveRouteDraft, + stampedEnvironmentProjectsLoading, updateNewTaskFlowState, } from "./new-task-flow-state"; @@ -68,6 +69,7 @@ describe("resolveRouteDraft", () => { draftsLoaded: true, stamp, projects: [project], + stampedEnvironmentLoading: false, }; it("binds the route's draft and its stamped project", () => { @@ -87,11 +89,26 @@ describe("resolveRouteDraft", () => { expect(resolveRouteDraft({ ...base, flow })).toEqual({ kind: "none" }); }); - it("waits for hydration, and for projects while none have loaded", () => { + it("waits for hydration, and for the stamped environment's projects", () => { expect(resolveRouteDraft({ ...base, draftsLoaded: false, stamp: undefined })).toEqual({ kind: "pending", }); - expect(resolveRouteDraft({ ...base, projects: [] })).toEqual({ kind: "pending" }); + expect(resolveRouteDraft({ ...base, projects: [], stampedEnvironmentLoading: true })).toEqual({ + kind: "pending", + }); + }); + + it("waits for a slower environment's projects while another environment's have loaded", () => { + const environmentB = EnvironmentId.make("env-b"); + const projectB = { environmentId: environmentB, id: ProjectId.make("project-b") }; + const stampB = { environmentId: environmentB, projectId: projectB.id }; + + expect(resolveRouteDraft({ ...base, stamp: stampB, stampedEnvironmentLoading: true })).toEqual({ + kind: "pending", + }); + expect( + resolveRouteDraft({ ...base, stamp: stampB, projects: [project, projectB] }), + ).toMatchObject({ kind: "open", flow: { environmentId: environmentB } }); }); it("reports a draft that is gone or whose project is gone", () => { @@ -101,3 +118,37 @@ describe("resolveRouteDraft", () => { ).toEqual({ kind: "missing" }); }); }); + +describe("stampedEnvironmentProjectsLoading", () => { + const settled = { + connectionsReady: true, + connectionState: "connected" as const, + shellCacheRead: true, + shellStatus: "live" as const, + shellHasError: false, + }; + + it("waits while the environment is still delivering its first project list", () => { + expect(stampedEnvironmentProjectsLoading({ ...settled, connectionsReady: false })).toBe(true); + expect(stampedEnvironmentProjectsLoading({ ...settled, shellCacheRead: false })).toBe(true); + expect(stampedEnvironmentProjectsLoading({ ...settled, connectionState: "connecting" })).toBe( + true, + ); + expect(stampedEnvironmentProjectsLoading({ ...settled, shellStatus: "synchronizing" })).toBe( + true, + ); + }); + + it("settles once the list is live, or when nothing more will arrive", () => { + expect(stampedEnvironmentProjectsLoading(settled)).toBe(false); + expect(stampedEnvironmentProjectsLoading({ ...settled, connectionState: null })).toBe(false); + expect( + stampedEnvironmentProjectsLoading({ + ...settled, + connectionState: "reconnecting", + shellStatus: "synchronizing", + }), + ).toBe(false); + expect(stampedEnvironmentProjectsLoading({ ...settled, shellHasError: true })).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-flow-state.ts b/apps/mobile/src/features/threads/new-task-flow-state.ts index 40088ec71d91..5b5fff2b2af1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-state.ts +++ b/apps/mobile/src/features/threads/new-task-flow-state.ts @@ -1,3 +1,5 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; @@ -86,6 +88,8 @@ export function resolveRouteDraft(input: { readonly environmentId: EnvironmentId; readonly id: ProjectId; }>; + /** See `stampedEnvironmentProjectsLoading`. */ + readonly stampedEnvironmentLoading: boolean; }): RouteDraftResolution { const { routeDraftId, stamp } = input; if (routeDraftId === undefined || input.flow.draftKey !== null) return { kind: "none" }; @@ -97,7 +101,7 @@ export function resolveRouteDraft(input: { const loaded = input.projects.some( (project) => project.environmentId === stamp.environmentId && project.id === stamp.projectId, ); - if (!loaded) return input.projects.length === 0 ? { kind: "pending" } : { kind: "missing" }; + if (!loaded) return input.stampedEnvironmentLoading ? { kind: "pending" } : { kind: "missing" }; return { kind: "open", flow: { @@ -109,6 +113,29 @@ export function resolveRouteDraft(input: { }; } +/** + * Whether the stamped environment may still deliver its project list. An + * unknown, offline, or failed environment is settled, so a project it lacks + * sends the route back to the picker. Mirrors `threadRouteIsHydrating`. + */ +export function stampedEnvironmentProjectsLoading(input: { + readonly connectionsReady: boolean; + readonly connectionState: EnvironmentConnectionPhase | null; + readonly shellCacheRead: boolean; + readonly shellStatus: EnvironmentShellStatus; + readonly shellHasError: boolean; +}): boolean { + if (input.shellHasError) return false; + if (!input.connectionsReady) return true; + if (input.connectionState === null) return false; + if (!input.shellCacheRead) return true; + // Only a first attempt is worth waiting out; a reconnecting environment's + // retries leave its shell synchronizing. + if (input.connectionState === "connecting") return true; + if (input.connectionState !== "connected") return false; + return input.shellStatus === "synchronizing" || input.shellStatus === "empty"; +} + interface NavigationRoutes { readonly routes: ReadonlyArray<{ readonly key: string; From 55fa95c9dad7edb7691b17ffd7f87e5fdb9dc612 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:57:50 -0700 Subject: [PATCH 4/4] fix(mobile): a draft waiting for its environment selects no project While a route draft's stamped environment is still loading, the provider fell back to the first loaded environment's first project, so the composer showed, and Start could send to, a project the user never chose. A pending route draft now has no selected project, which keeps the screen on its existing empty state until the draft opens. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/new-task-flow-provider.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 2fa848976845..87e2c5254dbc 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -310,12 +310,16 @@ export function NewTaskFlowProvider( Arr.filter((project) => project.environmentId === selectedEnvironmentId), ); + // A route draft still waiting for its environment has no project yet: the + // fallback would show, and could send to, another environment's project. const selectedProject = - projectsForEnvironment.find( - (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, - ) ?? - projectsForEnvironment[0] ?? - null; + routeDraftResolution.kind === "pending" + ? null + : (projectsForEnvironment.find( + (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, + ) ?? + projectsForEnvironment[0] ?? + null); const projectDefaults = useNewTaskProjectDefaults(selectedProject); const selectedEnvironmentServerConfig = projectDefaults.serverConfig;