feat(mobile): allow several new-task drafts per project#10327

merged3
t3code/mobile-drafts-in-list
t3code/mobile-multi-draft
openedSep 6, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago
6 unresolved threads ยท Jump to file
Selected thread: apps/mobile/src/state/use-composer-drafts.ts:284 โ€” ๐ŸŸ  **High** `state/use-composer-drafts.ts:284` Legacy `new-task:<environmentId>:<projectId>` drafts inside `signedOutDrafts[*].drafts` are restored without a `project` stamp, so `buildPendingNewTasks` excludes them and `openDraft` rejects them, making the archived content inaccessible. The same drafts also evade environment cleanup because their ownership is never migrated; apply `migrateLegacyNewTaskDraft` to each signed-out account's drafts before storing them. <details> <summary>๐Ÿš€ Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> ```text In file @apps/mobile/src/state/use-composer-drafts.ts around line 284: Legacy `new-task:<environmentId>:<projectId>` drafts inside `signedOutDrafts[*].drafts` are restored without a `project` stamp, so `buildPendingNewTasks` excludes them and `openDraft` rejects them, making the archived content inaccessible. The same drafts also evade environment cleanup because their ownership is never migrated; apply `migrateLegacyNewTaskDraft` to each signed-out account's drafts before storing them. ``` </details> <!-- macroscope-meta: {"kind":"code_review","variant":"f57f0b61c1d8f13d673564c863408287"} -->
0 / 15 viewed15 files changedLine totals unavailable

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx ยท Earlier revision 192f4a8a ยท right 438

Original diff
@@ -420,7 +422,24 @@ export function NewTaskDraftScreen(props: {
     };
   }, []);
 
-  const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow;
+  const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow;
+  // A Draft row opens its own draft; a fresh New Task never reuses one.
+  // Attempt each id once so a draft that was discarded while this screen is
+  // mounted does not keep bouncing to the picker.
+  const attemptedDraftIdRef = useRef<string | null>(null);
+  useEffect(() => {
+    if (!props.draftId || props.pendingTaskId) {
+      return;
+    }
+    if (attemptedDraftIdRef.current === props.draftId) {
+      return;
+    }
+    attemptedDraftIdRef.current = props.draftId;
+    if (!openDraft(props.draftId)) {
M
macroscopeappBotSep 6, 2026

๐ŸŸก Medium threads/NewTaskDraftScreen.tsx:438

On a cold launch, a persisted draftId is treated as missing and the route is immediately replaced with a fresh NewTask, so the existing draft cannot be opened. openDraft runs before asynchronous composer-draft hydration completes, and attemptedDraftIdRef prevents retrying once the draft arrives; wait for hydration before calling openDraft (or retry after it completes).

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/features/threads/NewTaskDraftScreen.tsx around line 438:
2
3On a cold launch, a persisted `draftId` is treated as missing and the route is immediately replaced with a fresh `NewTask`, so the existing draft cannot be opened. `openDraft` runs before asynchronous composer-draft hydration completes, and `attemptedDraftIdRef` prevents retrying once the draft arrives; wait for hydration before calling `openDraft` (or retry after it completes).
J
Julius MarmingeSep 6, 2026

Confirmed and fixed in 1e836229f. The draft screen now awaits waitForComposerDraftsLoaded() before calling openDraft, and if the draft exists but the project list is still empty it retries on the next projects change instead of replacing the route. Only once hydration is done and the draft or its project is genuinely absent does it fall back to the picker.

apps/mobile/src/features/threads/new-task-flow-provider.tsx ยท Earlier revision 192f4a8a ยท right 656

Original diff
@@ -625,19 +638,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
   const setProject = useCallback(
     (project: EnvironmentProject) => {
       const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
-      const nextDraftKey = `new-task:${nextProjectKey}`;
-      if (
-        selectedProjectDraftKey?.startsWith("new-task:") &&
-        selectedProjectDraftKey !== nextDraftKey
-      ) {
-        void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
+      const target = { environmentId: project.environmentId, projectId: project.id };
+      // Switching projects mid-compose keeps the same draft and moves it, so
+      // typed text follows the user; a pending-task edit owns its own key and
+      // is untouched here.
+      if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) {
+        retargetNewTaskDraft(activeDraftKey, target);
+      } else if (!editingPendingTaskRef.current) {
+        setActiveDraftKey(createNewTaskDraft(target));
       }
       setSelectedEnvironmentId(project.environmentId);
       setSelectedProjectKey(nextProjectKey);
     },
-    [selectedProjectDraftKey],
+    [activeDraftKey],
   );
 
+  const openDraft = useCallback((draftKey: string): boolean => {
M
macroscopeappBotSep 6, 2026

๐ŸŸ  High threads/new-task-flow-provider.tsx:656

Opening an orphaned draft submits its content to projectsForEnvironment[0], so a deleted or temporarily unloaded stamped project is silently replaced by an unrelated project. openDraft only checks the draft stamp; reject the draft when no matching project exists so the caller returns to the picker instead.

Also found in 2 other location(s)

apps/mobile/src/features/home/usePendingTaskListActions.ts:25

Passing draftId makes the draft screen bypass its route-project availability fallback. If the draft's stamped project was deleted or is no longer loaded, openDraft sets an unavailable key; the flow then falls back to projectsForEnvironment[0] while retaining the draft's content, so submitting this row starts the task in an unrelated first project instead of returning to the picker.

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx:438

This accepts any persisted draft whose project stamp exists, even when that stamped project is no longer present in projects. openDraft sets the missing project's key, but the flow resolves selectedProject by falling back to projectsForEnvironment[0]; meanwhile it still reads the opened draft through activeDraftKey. Thus opening a draft after its project is removed or temporarily absent can display and submit that draft (including attachments) to the first unrelated project in the same environment.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around line 656:
2
3Opening an orphaned draft submits its content to `projectsForEnvironment[0]`, so a deleted or temporarily unloaded stamped project is silently replaced by an unrelated project. `openDraft` only checks the draft stamp; reject the draft when no matching project exists so the caller returns to the picker instead.
4
5Also found in 2 other location(s):
6- apps/mobile/src/features/home/usePendingTaskListActions.ts:25 -- Passing `draftId` makes the draft screen bypass its route-project availability fallback. If the draft's stamped project was deleted or is no longer loaded, `openDraft` sets an unavailable key; the flow then falls back to `projectsForEnvironment[0]` while retaining the draft's content, so submitting this row starts the task in an unrelated first project instead of returning to the picker.
7- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx:438 -- This accepts any persisted draft whose `project` stamp exists, even when that stamped project is no longer present in `projects`. `openDraft` sets the missing project's key, but the flow resolves `selectedProject` by falling back to `projectsForEnvironment[0]`; meanwhile it still reads the opened draft through `activeDraftKey`. Thus opening a draft after its project is removed or temporarily absent can display and submit that draft (including attachments) to the first unrelated project in the same environment.
J
Julius MarmingeSep 6, 2026

Confirmed and fixed in 1e836229f. openDraft now returns false when the draft's stamped project is not in the loaded project list, so the draft screen falls back to the picker instead of the composer binding to projectsForEnvironment[0]. Combined with the hydration wait above, a temporarily unloaded project retries rather than misrouting.

apps/mobile/src/state/use-composer-drafts.ts ยท Earlier revision 192f4a8a ยท right 243

Original diff
@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
   );
 }
 
+/**
+ * Writes a draft back, dropping it once empty. A new-task draft keeps its
+ * entry while the composer is bound to it (the project stamp is what the
+ * composer binds to); the persist sweep still leaves empty ones off disk.
+ */
+function withComposerDraft(
+  current: Record<string, ComposerDraft>,
+  draftKey: string,
+  draft: ComposerDraft,
+): Record<string, ComposerDraft> {
+  if (isEmptyDraft(draft) && draft.project === undefined) {
+    const next = { ...current };
+    delete next[draftKey];
+    return next;
+  }
+  return { ...current, [draftKey]: draft };
+}
+
+export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key";
+
+// Draft ids only need to be unique within this device's draft file. Deriving
+// them from time plus randomness keeps this module free of native imports,
+// which the persistence tests rely on.
+function newDraftId(): string {
+  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
M
macroscopeappBotSep 6, 2026

๐ŸŸ  High state/use-composer-drafts.ts:243

Signing out of a relay environment permanently drops every unsent new-task draft created here. newTaskDraftKey(newDraftId()) produces a key containing only the timestamp and random suffix, so archiveCloudComposerDrafts cannot recover the environment via composerDraftEnvironmentId(key, queued) and skips the draft; cleanup then deletes it using project.environmentId. Include the environment in new-task keys (or otherwise make it recoverable by composerDraftEnvironmentId).

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/state/use-composer-drafts.ts around line 243:
2
3Signing out of a relay environment permanently drops every unsent new-task draft created here. `newTaskDraftKey(newDraftId())` produces a key containing only the timestamp and random suffix, so `archiveCloudComposerDrafts` cannot recover the environment via `composerDraftEnvironmentId(key, queued)` and skips the draft; cleanup then deletes it using `project.environmentId`. Include the environment in new-task keys (or otherwise make it recoverable by `composerDraftEnvironmentId`).
J
Julius MarmingeSep 6, 2026

Confirmed and fixed in 1e836229f, same root cause as the upload finding: archiveCloudComposerDrafts now resolves the environment from the draft's project stamp, so id-keyed drafts are archived with their environment instead of being skipped and then swept.

apps/mobile/src/state/use-composer-drafts.ts ยท Earlier revision 192f4a8a ยท right 243

Original diff
@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
   );
 }
 
+/**
+ * Writes a draft back, dropping it once empty. A new-task draft keeps its
+ * entry while the composer is bound to it (the project stamp is what the
+ * composer binds to); the persist sweep still leaves empty ones off disk.
+ */
+function withComposerDraft(
+  current: Record<string, ComposerDraft>,
+  draftKey: string,
+  draft: ComposerDraft,
+): Record<string, ComposerDraft> {
+  if (isEmptyDraft(draft) && draft.project === undefined) {
+    const next = { ...current };
+    delete next[draftKey];
+    return next;
+  }
+  return { ...current, [draftKey]: draft };
+}
+
+export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key";
+
+// Draft ids only need to be unique within this device's draft file. Deriving
+// them from time plus randomness keeps this module free of native imports,
+// which the persistence tests rely on.
+function newDraftId(): string {
+  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
M
macroscopeappBotSep 6, 2026

๐ŸŸ  High state/use-composer-drafts.ts:243

New drafts created by createNewTaskDraft cannot be associated with an environment by composerDraftEnvironmentId, so attachment uploads never start and connected drafts are permanently blocked as Attachment still uploading. newDraftId() supplies only a time/random token, while the worker expects the new-task:<environmentId>:<projectId> key format; include the environment and project segments in the created key (or otherwise provide that metadata to the worker).

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/state/use-composer-drafts.ts around line 243:
2
3New drafts created by `createNewTaskDraft` cannot be associated with an environment by `composerDraftEnvironmentId`, so attachment uploads never start and connected drafts are permanently blocked as `Attachment still uploading`. `newDraftId()` supplies only a time/random token, while the worker expects the `new-task:<environmentId>:<projectId>` key format; include the environment and project segments in the created key (or otherwise provide that metadata to the worker).
J
Julius MarmingeSep 6, 2026

Confirmed and fixed in 1e836229f. composerDraftEnvironmentId now takes the draft and reads project.environmentId from the stamp; the legacy key parse only applies to drafts that have not been migrated yet. All three callers (upload sync, upload persistence, sign-out archival) pass the draft. Covered by a new case in composerAttachmentUploadQueue.test.ts.

apps/mobile/src/state/use-composer-drafts.ts ยท Earlier revision 192f4a8a ยท right 284

Original diff
@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
   );
 }
 
+/**
+ * Writes a draft back, dropping it once empty. A new-task draft keeps its
+ * entry while the composer is bound to it (the project stamp is what the
+ * composer binds to); the persist sweep still leaves empty ones off disk.
+ */
+function withComposerDraft(
+  current: Record<string, ComposerDraft>,
+  draftKey: string,
+  draft: ComposerDraft,
+): Record<string, ComposerDraft> {
+  if (isEmptyDraft(draft) && draft.project === undefined) {
+    const next = { ...current };
+    delete next[draftKey];
+    return next;
+  }
+  return { ...current, [draftKey]: draft };
+}
+
+export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key";
+
+// Draft ids only need to be unique within this device's draft file. Deriving
+// them from time plus randomness keeps this module free of native imports,
+// which the persistence tests rely on.
+function newDraftId(): string {
+  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+}
+
+/**
+ * Project-keyed new-task drafts from earlier builds are rewritten on load into
+ * id-keyed drafts with the project stamped in, so existing drafts survive the
+ * switch to many-per-project.
+ */
+export function migrateLegacyNewTaskDraft(
+  key: string,
+  draft: ComposerDraft,
+  now: string,
+): readonly [key: string, draft: ComposerDraft] {
+  const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null;
+  if (legacy === null) {
+    return [key, draft];
+  }
+  return [
+    newTaskDraftKey(newDraftId()),
+    {
+      ...draft,
+      project: {
+        environmentId: EnvironmentIdSchema.make(legacy.environmentId),
+        projectId: ProjectIdSchema.make(legacy.projectId),
+        createdAt: now,
+      },
+    },
+  ];
+}
+
 export function decodePersistedComposerState(value: unknown): {
   readonly drafts: Record<string, ComposerDraft>;
   readonly stickyModelSelection: ModelSelection | null;
   readonly cloudDrafts: ComposerCloudDraftState;
 } {
   const parsed = decodePersistedComposerDraftsDocument(value);
+  const now = new Date().toISOString();
   return {
     drafts: Object.fromEntries(
       Object.entries(parsed.drafts)
-        .map(
-          ([key, draft]) =>
-            [
-              key,
-              // Stale new-task drafts left on disk by builds before the
-              // model-precedence fix carry a bare modelSelection with no
-              // other selector settings. Strip it so the next compose pass
-              // re-resolves project โ†’ sticky โ†’ provider defaults. Drafts
-              // with runtime/interaction/workspace settings or actual text /
-              // attachments were deliberately configured and are left alone.
-              key.startsWith("new-task:") &&
+        .map(([key, draft]) =>
+          migrateLegacyNewTaskDraft(
M
macroscopeappBotSep 6, 2026

๐ŸŸ  High state/use-composer-drafts.ts:284

Legacy new-task:<environmentId>:<projectId> drafts inside signedOutDrafts[*].drafts are restored without a project stamp, so buildPendingNewTasks excludes them and openDraft rejects them, making the archived content inaccessible. The same drafts also evade environment cleanup because their ownership is never migrated; apply migrateLegacyNewTaskDraft to each signed-out account's drafts before storing them.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/state/use-composer-drafts.ts around line 284:
2
3Legacy `new-task:<environmentId>:<projectId>` drafts inside `signedOutDrafts[*].drafts` are restored without a `project` stamp, so `buildPendingNewTasks` excludes them and `openDraft` rejects them, making the archived content inaccessible. The same drafts also evade environment cleanup because their ownership is never migrated; apply `migrateLegacyNewTaskDraft` to each signed-out account's drafts before storing them.
J
Julius MarmingeSep 6, 2026

Confirmed and fixed in 1e836229f. decodePersistedComposerState now runs migrateLegacyNewTaskDraft over each signed-out account's drafts as well as the live ones. Test: "migrates archived signed-out new-task drafts the same way as live ones".

apps/mobile/src/features/threads/new-task-flow-provider.tsx ยท File comment

M
macroscopeappBotSep 6, 2026

๐ŸŸ  High

https://github.com/pingdotgg/t3code/blob/192f4a8a263f070c312c24f9d20a26e42a0a393a/apps/mobile/src/features/threads/new-task-flow-provider.tsx#L692

Switching environments while composing sends the draft using the matched project but leaves its project stamp and workspace selection tied to the old project, so the draft is later shown under the wrong project and may retain an invalid old-machine worktree path. selectEnvironment changes selectedProjectKey without retargeting activeDraftKey; retarget the active new-task draft to match before updating the selection.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around line 692:
2
3Switching environments while composing sends the draft using the matched project but leaves its `project` stamp and workspace selection tied to the old project, so the draft is later shown under the wrong project and may retain an invalid old-machine worktree path. `selectEnvironment` changes `selectedProjectKey` without retargeting `activeDraftKey`; retarget the active new-task draft to `match` before updating the selection.
J
Julius MarmingeSep 6, 2026

Confirmed and fixed in 1e836229f. selectEnvironment now retargets the active new-task draft to the matched project (which also clears the old machine's workspace selection), mirroring what setProject does.