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..d5b5ff3c1370 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,12 @@ 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 isRouteDraftMissing = + props.draftId !== undefined && flow.missingRouteDraftId === props.draftId; const isProjectPickerReturnActive = - isReturningToProjectPicker && !requestedInitialProjectAvailable; + (isReturningToProjectPicker && !requestedInitialProjectAvailable) || isRouteDraftMissing; const isIncomingShareAwaitingServerConfig = Boolean( incomingShare?.attachments.some((attachment) => attachment.type === "file") && selectedEnvironmentServerConfig === null, @@ -373,12 +371,11 @@ export function NewTaskDraftScreen(props: { isIncomingShareUnavailable; const appliedInitialProjectKeyRef = useRef(null); useEffect(() => { - if (!isReturningToProjectPicker) { - 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); + } + if (!isProjectPickerReturnActive) { return; } // Wait for usePreventRemove to disable before replacing, or the transfer guard swallows the fallback. @@ -389,41 +386,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..87e2c5254dbc 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,18 @@ 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, + stampedEnvironmentProjectsLoading, + updateNewTaskFlowState, +} from "./new-task-flow-state"; +import { useEnvironmentShellReadiness } from "../../state/shell"; +import { useConnectionsReady, useWorkspaceEnvironments } from "../../state/workspace"; type WorkspaceMode = "local" | "worktree"; @@ -153,10 +166,15 @@ 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 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; @@ -197,6 +215,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,38 +237,89 @@ 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, ); + // 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, + stamp: Parameters[0]["stamp"], + ): RouteDraftResolution => + resolveRouteDraft({ + flow, + routeDraftId: props.routeDraftId, + draftsLoaded: loaded, + stamp, + projects, + stampedEnvironmentLoading, + }); + 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), ); + // 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; @@ -318,17 +389,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 +605,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 +628,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 +858,10 @@ export function NewTaskFlowProvider( selectedModelOption, selectedProviderStatus, providerGroups, - reset, setProject, - openDraft, + getSelectedProjectKey: () => readFlow().projectKey, + missingRouteDraftId: + routeDraftResolution.kind === "missing" ? (props.routeDraftId ?? null) : null, selectEnvironment, switchEnvironment, switchingToEnvironmentId, @@ -813,12 +875,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..73d2a2af4cb3 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-flow-state.test.ts @@ -0,0 +1,154 @@ +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, + stampedEnvironmentProjectsLoading, + 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], + stampedEnvironmentLoading: false, + }; + + 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 the stamped environment's projects", () => { + expect(resolveRouteDraft({ ...base, draftsLoaded: false, stamp: undefined })).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", () => { + expect(resolveRouteDraft({ ...base, stamp: undefined })).toEqual({ kind: "missing" }); + expect( + resolveRouteDraft({ ...base, projects: [{ environmentId, id: ProjectId.make("other") }] }), + ).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 37a04a0b4de3..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,9 +1,20 @@ +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"; + +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 +24,118 @@ 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; + }>; + /** See `stampedEnvironmentProjectsLoading`. */ + readonly stampedEnvironmentLoading: boolean; +}): 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.stampedEnvironmentLoading ? { kind: "pending" } : { kind: "missing" }; + return { + kind: "open", + flow: { + ...input.flow, + environmentId: stamp.environmentId, + projectKey: scopedProjectKey(stamp.environmentId, stamp.projectId), + draftKey: routeDraftId, + }, + }; +} + +/** + * 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; @@ -30,3 +153,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..70c9503f34c2 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -471,6 +471,16 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.withLabel("mobile:composer-drafts"), ); +/** + * 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-loaded"), +); + export const stickyComposerModelSelectionAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-model-selection"), @@ -507,6 +517,7 @@ const persistenceQueue = new SerializedAsyncQueue(); export function resetComposerDraftsLoadState(): void { loadPromise = null; + appAtomRegistry.set(composerDraftsLoadedAtom, false); persistRetryNeeded = false; } @@ -1039,6 +1050,7 @@ export function ensureComposerDraftsLoaded(): void { ), }); } + appAtomRegistry.set(composerDraftsLoadedAtom, true); }); loadPromise = loading; void loading.catch((cause) => {