diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 2bc26707324e..e10fdc64649f 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -281,11 +281,15 @@ 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; + // returns to the picker; a draft that survives goes along with it. + const unopenableRouteDraftKind = + props.draftId !== undefined && flow.unopenableRouteDraft?.draftId === props.draftId + ? flow.unopenableRouteDraft.kind + : null; + const pickerDraftId = unopenableRouteDraftKind === "pickProject" ? props.draftId : undefined; const isProjectPickerReturnActive = - (isReturningToProjectPicker && !requestedInitialProjectAvailable) || isRouteDraftMissing; + (isReturningToProjectPicker && !requestedInitialProjectAvailable) || + unopenableRouteDraftKind !== null; const isIncomingShareAwaitingServerConfig = Boolean( incomingShare?.attachments.some((attachment) => attachment.type === "file") && selectedEnvironmentServerConfig === null, @@ -388,7 +392,10 @@ export function NewTaskDraftScreen(props: { // Wait for usePreventRemove to disable before replacing, or the transfer guard swallows the fallback. const frame = requestAnimationFrame(() => { navigation.dispatch( - StackActions.replace("NewTaskProject", { incomingShareId: props.incomingShareId }), + StackActions.replace("NewTaskProject", { + incomingShareId: props.incomingShareId, + draftId: pickerDraftId, + }), ); }); return () => cancelAnimationFrame(frame); @@ -396,6 +403,7 @@ export function NewTaskDraftScreen(props: { isProjectPickerReturnActive, isReturningToProjectPicker, navigation, + pickerDraftId, props.incomingShareId, requestedInitialProjectAvailable, ]); diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index d904c3c67c54..ac9e2ce81363 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -29,6 +29,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useProjects, useServerConfigs } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { useAtomCommand } from "../../state/use-atom-command"; +import { retargetSavedNewTaskDraft } from "../../state/use-composer-drafts"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; @@ -44,6 +45,8 @@ import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspace type NewTaskRouteParams = { readonly incomingShareId?: string | string[]; + /** An existing draft whose project is gone; the chosen project takes it over. */ + readonly draftId?: string | string[]; readonly prompt?: string | string[]; readonly repo?: string | string[]; readonly pr?: string | string[]; @@ -164,6 +167,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps found, + () => null, + ); + if (retargeted === null) { + Dialog.alert("Could not change project", "Saved drafts could not be loaded. Try again."); + return; + } + const draftId = retargeted ? routeDraftId : undefined; selectDetail({ name: "Thread", params: { draft: { environmentId: String(project.environmentId), projectId: String(project.id), + draftId, incomingShareId: incomingShare?.id, }, }, 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 5b42c28adc51..0e0bd6dea208 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -168,11 +168,14 @@ type NewTaskFlowContextValue = { /** 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. + * The route this flow serves when its draft cannot open: the draft is gone + * (`missing`) or only its project is (`pickProject`). Only the topmost draft + * route is served, so a screen compares `draftId` with its own draft id. */ - readonly missingRouteDraftId: string | null; + readonly unopenableRouteDraft: { + readonly draftId: string; + readonly kind: "missing" | "pickProject"; + } | null; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly switchEnvironment: (environmentId: EnvironmentId) => Promise; readonly switchingToEnvironmentId: EnvironmentId | null; @@ -851,8 +854,11 @@ export function NewTaskFlowProvider( providerGroups, setProject, getSelectedProjectKey: () => readFlow().projectKey, - missingRouteDraftId: - routeDraftResolution.kind === "missing" ? (props.routeDraftId ?? null) : null, + unopenableRouteDraft: + props.routeDraftId !== undefined && + (routeDraftResolution.kind === "missing" || routeDraftResolution.kind === "pickProject") + ? { draftId: props.routeDraftId, kind: routeDraftResolution.kind } + : null, selectEnvironment, switchEnvironment, switchingToEnvironmentId, 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 73d2a2af4cb3..d0783714882f 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 @@ -111,11 +111,14 @@ describe("resolveRouteDraft", () => { ).toMatchObject({ kind: "open", flow: { environmentId: environmentB } }); }); - it("reports a draft that is gone or whose project is gone", () => { + it("reports a draft that is gone", () => { expect(resolveRouteDraft({ ...base, stamp: undefined })).toEqual({ kind: "missing" }); + }); + + it("asks for a project for the same draft when its project is gone", () => { expect( resolveRouteDraft({ ...base, projects: [{ environmentId, id: ProjectId.make("other") }] }), - ).toEqual({ kind: "missing" }); + ).toEqual({ kind: "pickProject" }); }); }); 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 5b5fff2b2af1..256a2022997e 100644 --- a/apps/mobile/src/features/threads/new-task-flow-state.ts +++ b/apps/mobile/src/features/threads/new-task-flow-state.ts @@ -75,6 +75,8 @@ export type RouteDraftResolution = | { readonly kind: "none" } | { readonly kind: "pending" } | { readonly kind: "missing" } + /** The draft survives but its project is gone: pick another for it. */ + | { readonly kind: "pickProject" } | { readonly kind: "open"; readonly flow: NewTaskFlowState }; export function resolveRouteDraft(input: { @@ -101,7 +103,8 @@ export function resolveRouteDraft(input: { const loaded = input.projects.some( (project) => project.environmentId === stamp.environmentId && project.id === stamp.projectId, ); - if (!loaded) return input.stampedEnvironmentLoading ? { kind: "pending" } : { kind: "missing" }; + if (!loaded) + return input.stampedEnvironmentLoading ? { kind: "pending" } : { kind: "pickProject" }; return { kind: "open", flow: { diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index f8a14977acca..f7fe9ebbcf57 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -205,6 +205,7 @@ import { restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, retargetNewTaskDraft, + retargetSavedNewTaskDraft, setComposerDraftText, insertComposerDraftContext, insertComposerDraftText, @@ -1933,6 +1934,30 @@ describe("mobile composer drafts", () => { expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]); }); + it("retargets a saved new-task draft only after drafts load", async () => { + const from = { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + }; + const to = { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-2"), + }; + const key = createNewTaskDraft(from); + setComposerDraftText(key, "kept through a cold start"); + await flushComposerDrafts(); + appAtomRegistry.set(composerDraftsAtom, {}); + resetComposerDraftsLoadState(); + + await expect(retargetSavedNewTaskDraft(key, to)).resolves.toBe(true); + expect(getComposerDraftSnapshot(key)).toMatchObject({ + text: "kept through a cold start", + project: to, + }); + await expect(retargetSavedNewTaskDraft(`${key}-gone`, to)).resolves.toBe(false); + expect(appAtomRegistry.get(composerDraftsAtom)[`${key}-gone`]).toBeUndefined(); + }); + it("merges persisted option memory without replacing newer choices", async () => { composerDraftFileMocks.setDocument({ schemaVersion: 1, diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index b233034c5cf8..2e4e53ef6dd4 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1999,6 +1999,17 @@ export function retargetNewTaskDraft( }); } +/** Retargets a saved draft once drafts load; an absent one is not created. */ +export async function retargetSavedNewTaskDraft( + draftKey: string, + project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, +): Promise { + await waitForComposerDraftsLoaded(); + if (appAtomRegistry.get(composerDraftsAtom)[draftKey] === undefined) return false; + retargetNewTaskDraft(draftKey, project); + return true; +} + export function findNewTaskDraftKeys( drafts: Readonly>, project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },