From 107519658cc1c3c36cdf0f65cedbaadfb6f51d3b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 03:00:12 +0000 Subject: [PATCH 1/5] test(mobile): reproduce #26785, a draft whose project is gone keeps its identity through the picker resolveRouteDraft reports a stamped draft whose project is no longer loaded as missing, the same as a deleted draft, so the draft screen sends the user to the project picker without the draft and choosing a project opens an empty composer. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/new-task-flow-state.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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..15fb9a07f0cb 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", draftId: "new-task:draft-1" }); }); }); From 2a2e0d425aa0ce8eba6cab5d4a1fa3072501b004 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 03:04:29 +0000 Subject: [PATCH 2/5] fix(mobile): a draft whose project is gone keeps its text through the project picker Opening an Unsent draft whose stamped project no longer exists sent the user to "Choose project", but picking a project opened a fresh, empty draft: the route resolution treated "draft gone" and "project gone" alike. The resolver now reports `pickProject` with the draft id when only the project is gone. The draft screen hands that id to the picker, which retargets the draft to the chosen project and opens it, so the composer shows the saved text and attachments. Backing out of the picker leaves the draft untouched; a draft that is truly gone still gets a fresh one. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/NewTaskDraftScreen.tsx | 18 +++++++++++++----- .../features/threads/NewTaskRouteScreen.tsx | 16 +++++++++++++++- .../threads/new-task-flow-provider.tsx | 18 ++++++++++++------ .../features/threads/new-task-flow-state.ts | 8 +++++++- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index a21fcf039aca..6e19bcb1678a 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -275,11 +275,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, @@ -382,7 +386,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); @@ -390,6 +397,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 12b2a893e502..27d4bbf98a73 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 { retargetNewTaskDraft } from "../../state/use-composer-drafts"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; @@ -40,6 +41,8 @@ import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail" 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[]; @@ -162,6 +165,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps 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; @@ -879,8 +882,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.ts b/apps/mobile/src/features/threads/new-task-flow-state.ts index 5b5fff2b2af1..75e55dd65fc6 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 draftId: string } | { readonly kind: "open"; readonly flow: NewTaskFlowState }; export function resolveRouteDraft(input: { @@ -101,7 +103,11 @@ 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", draftId: routeDraftId }; + } return { kind: "open", flow: { From 8e42569b7904488acc669ebd978f09378fccdc89 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:25:34 +0000 Subject: [PATCH 3/5] refactor(mobile): drop the redundant draft id from the pickProject resolution AI-assisted; verified with the tests and tools listed above. --- .../src/features/threads/new-task-flow-state.test.ts | 2 +- apps/mobile/src/features/threads/new-task-flow-state.ts | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) 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 15fb9a07f0cb..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 @@ -118,7 +118,7 @@ describe("resolveRouteDraft", () => { 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: "pickProject", draftId: "new-task:draft-1" }); + ).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 75e55dd65fc6..256a2022997e 100644 --- a/apps/mobile/src/features/threads/new-task-flow-state.ts +++ b/apps/mobile/src/features/threads/new-task-flow-state.ts @@ -76,7 +76,7 @@ export type RouteDraftResolution = | { readonly kind: "pending" } | { readonly kind: "missing" } /** The draft survives but its project is gone: pick another for it. */ - | { readonly kind: "pickProject"; readonly draftId: string } + | { readonly kind: "pickProject" } | { readonly kind: "open"; readonly flow: NewTaskFlowState }; export function resolveRouteDraft(input: { @@ -103,11 +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: "pickProject", draftId: routeDraftId }; - } + if (!loaded) + return input.stampedEnvironmentLoading ? { kind: "pending" } : { kind: "pickProject" }; return { kind: "open", flow: { From 02cd5823244abe0a82c08dc04a4baf474bf56b0a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:11:46 +0000 Subject: [PATCH 4/5] fix(mobile): the project picker waits for saved drafts before retargeting one A cold-start link to the picker with a draft id could retarget before drafts hydrated, creating an empty entry that overwrote the saved draft. Wait for drafts to load and retarget only a draft that exists. AI-assisted; verified with the tests and tools listed above. --- .../features/threads/NewTaskRouteScreen.tsx | 14 ++++++----- .../src/state/use-composer-drafts.test.ts | 25 +++++++++++++++++++ apps/mobile/src/state/use-composer-drafts.ts | 11 ++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 5c49534a8b5a..0b59051a0c3a 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -29,7 +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 { retargetNewTaskDraft } from "../../state/use-composer-drafts"; +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"; @@ -240,19 +240,21 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { 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 }, From eb86c4ad9d8aa6faed38f48c4cb50cd8110371dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 23:57:23 +0000 Subject: [PATCH 5/5] fix(mobile): the project picker reports drafts that fail to load instead of stalling AI-assisted; verified with the tests and tools listed above. --- .../features/threads/NewTaskRouteScreen.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 0b59051a0c3a..ac9e2ce81363 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -240,14 +240,21 @@ 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: {