# Files changed · PR \#10327 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10327/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(mobile\): allow several new-task drafts per project](https://git.cafe/pingdotgg/t3code/pull/10327.md)

Version: 9

Head commit: c1157a308da82eaa9fef9f54c7457a9df186db89

Base commit: 0286e753379f59474ff9bf68b4459a3c15c2798a

Comparison base: 0286e753379f59474ff9bf68b4459a3c15c2798a

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/home/usePendingTaskListActions.ts

Status: modified; +1; −3

```diff
@@ -15,8 +15,6 @@
 
   const openPendingTask = useCallback(
     (pendingTask: PendingNewTask) => {
-      // A draft is the project's own new-task composer content, so opening
-      // the project's new-task screen lands on it without extra params.
       navigation.navigate("NewTaskSheet", {
         screen: "NewTaskDraft",
         params: {
@@ -24,7 +22,7 @@
           projectId: String(pendingTask.projectId),
           ...(pendingTask.kind === "pending"
             ? { pendingTaskId: String(pendingTask.message.messageId) }
-            : {}),
+            : { draftId: pendingTask.draftKey }),
         },
       });
     },
```

## apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx

Status: modified; +2; −0

```diff
@@ -9,6 +9,7 @@
   readonly projectId?: string | string[];
   readonly title?: string | string[];
   readonly pendingTaskId?: string | string[];
+  readonly draftId?: string | string[];
   readonly incomingShareId?: string | string[];
 };
 
@@ -43,6 +44,7 @@
         pendingTaskId={
           Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
         }
+        draftId={Array.isArray(params.draftId) ? params.draftId[0] : params.draftId}
       />
     </>
   );
```

## apps/mobile/src/features/threads/NewTaskDraftScreen.tsx

Status: modified; +46; −4

```diff
@@ -83,6 +83,7 @@
   restoreComposerDraftSnapshot,
   scheduleUnusedComposerAttachmentCleanup,
   type ComposerDraft,
+  waitForComposerDraftsLoaded,
 } from "../../state/use-composer-drafts";
 import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
 import {
@@ -150,6 +151,8 @@
   };
   /** Queued outbox message id when editing an existing pending task. */
   readonly pendingTaskId?: string;
+  /** Existing new-task draft key to resume (a Draft row in the thread list). */
+  readonly draftId?: string;
   /** Durable native share inbox item to merge into this project draft. */
   readonly incomingShareId?: string;
 }) {
@@ -420,7 +423,44 @@
     };
   }, []);
 
+  const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow;
+  // A Draft row opens its own draft; a fresh New Task never reuses one.
+  // Drafts hydrate from disk and projects arrive with the shell snapshot, so
+  // on a cold launch the draft or its project can be missing for a moment;
+  // wait for hydration and retry while projects load. Attempt each id once
+  // after that so a draft discarded mid-session does not keep bouncing to
+  // the picker.
+  const attemptedDraftIdRef = useRef<string | null>(null);
+  useEffect(() => {
+    if (!props.draftId || props.pendingTaskId) {
+      return;
+    }
-  const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow;
+    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) {
+        // The draft exists; its project has not arrived yet. Retry on the
+        // next projects change instead of giving up.
+        return;
+      }
+      attemptedDraftIdRef.current = draftId;
+      navigation.dispatch(StackActions.replace("NewTask"));
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]);
+
   const attemptedPendingTaskIdRef = useRef<string | null>(null);
   useEffect(() => {
     if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) {
@@ -457,9 +497,10 @@
   const lastInitialProjectRefRef = useRef(props.initialProjectRef);
 
   useEffect(() => {
-    // Pending-task editing owns project selection (and must not fall through
-    // to the replace("NewTask") fallback while its hydration is in flight).
+    // Pending-task editing and draft resumption own project selection (and
+    // must not fall through to the replace("NewTask") fallback while their
-    if (props.pendingTaskId) {
+    // hydration is in flight).
+    if (props.pendingTaskId || props.draftId) {
       return;
     }
     if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
@@ -518,6 +559,7 @@
     props.initialProjectRef,
     props.incomingShareId,
     props.pendingTaskId,
+    props.draftId,
     navigation,
     selectedProject,
     selectedProjectKey,
```

## apps/mobile/src/features/threads/new-task-flow-provider.tsx

Status: modified; +68; −13

```diff
@@ -43,11 +43,14 @@
 import {
   appendComposerDraftAttachments,
   clearComposerDraft,
+  composerDraftsAtom,
+  createNewTaskDraft,
-  copyComposerDraftContentIfEmpty,
   getComposerDraftSnapshot,
   isComposerDraftEmpty,
+  isNewTaskDraftKey,
   removeComposerDraftAttachment,
   replaceComposerDraftAttachments,
+  retargetNewTaskDraft,
   scheduleUnusedComposerAttachmentCleanup,
   setComposerDraftText,
   setStickyComposerModelSelection,
@@ -169,6 +172,12 @@
   readonly filteredBranches: ReadonlyArray<VcsRef>;
   readonly reset: () => void;
   readonly setProject: (project: EnvironmentProject) => void;
+  /**
+   * Binds the composer to an existing new-task draft (a row in the thread
+   * list). Returns false when the draft is gone, so the caller can fall back
+   * to a fresh one.
+   */
+  readonly openDraft: (draftKey: string) => boolean;
   readonly selectEnvironment: (environmentId: EnvironmentId) => void;
   readonly setSelectedModelKey: (
     key: string | null,
@@ -232,6 +241,9 @@
       ? selectedEnvironmentIdOverride
       : (projects[0]?.environmentId ?? null);
   const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
+  // The new-task draft the composer is bound to. Null until a project is
+  // chosen; each New Task entry mints its own, so a project can hold several.
+  const [activeDraftKey, setActiveDraftKey] = useState<string | null>(null);
   const [submitting, setSubmitting] = useState(false);
   const [branchQuery, setBranchQuery] = useState("");
   const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
@@ -247,6 +259,7 @@
   const reset = useCallback(() => {
     setSelectedEnvironmentId(null);
     setSelectedProjectKey(null);
+    setActiveDraftKey(null);
     setSubmitting(false);
     setBranchQuery("");
     setExpandedProvider(null);
@@ -367,12 +380,28 @@
     selectedProject?.environmentId ?? null,
   );
   // While a queued pending task is being edited its draft lives under a key
-  // scoped to the queued message, so per-project new-task drafts stay intact.
+  // scoped to the queued message, so new-task drafts stay intact.
   const selectedProjectDraftKey = editingPendingTask
     ? pendingTaskDraftKey(editingPendingTask.messageId)
     : selectedProject
+      ? activeDraftKey
-      ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
       : null;
+  // selectedProject can resolve without setProject ever running (the
+  // environment's first project is the fallback, and the draft screen skips
+  // setProject when the route's project already matches it). The composer
+  // still needs a draft to write into, so bind one the moment a project is
+  // in view and nothing else owns the key.
+  useEffect(() => {
+    if (activeDraftKey !== null || editingPendingTask !== null || selectedProject === null) {
+      return;
+    }
+    setActiveDraftKey(
+      createNewTaskDraft({
+        environmentId: selectedProject.environmentId,
+        projectId: selectedProject.id,
+      }),
+    );
+  }, [activeDraftKey, editingPendingTask, selectedProject]);
   const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
   const prompt = selectedProjectDraft.text;
   const attachments = selectedProjectDraft.attachments;
@@ -625,20 +654,19 @@
     );
   }, [availableBranches, branchQuery]);
 
-  // New-task drafts are keyed per (environment, project), so retargeting the
+  // The composer's draft follows the project it will be sent to: switching
+  // 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.
-  // composer would otherwise show the target's empty draft and strand what the
-  // user typed under the old key.
   const carryDraftContentTo = useCallback(
     (project: EnvironmentProject) => {
-      const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`;
-      if (
-        selectedProjectDraftKey?.startsWith("new-task:") &&
-        selectedProjectDraftKey !== nextDraftKey
-      ) {
+      const target = { environmentId: project.environmentId, projectId: project.id };
+      if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) {
+        retargetNewTaskDraft(activeDraftKey, target);
-        void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
+      } else if (!editingPendingTaskRef.current) {
+        setActiveDraftKey(createNewTaskDraft(target));
       }
     },
+    [activeDraftKey],
-    [selectedProjectDraftKey],
   );
 
   const setProject = useCallback(
@@ -650,6 +678,31 @@
     [carryDraftContentTo],
   );
 
+  const openDraft = useCallback(
+    (draftKey: string): boolean => {
+      const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
+      const stamp = draft?.project;
+      if (!isNewTaskDraftKey(draftKey) || !stamp) {
+        return false;
+      }
+      // The stamped project must be loaded: selectedProject falls back to
+      // the environment's first project otherwise, and the draft would be
+      // sent somewhere the user never chose.
+      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;
+    },
+    [projects],
+  );
+
   const selectEnvironment = useCallback(
     (environmentId: EnvironmentId) => {
       const match = resolveEnvironmentProjectMatch(
@@ -1085,6 +1138,7 @@
       filteredBranches,
       reset,
       setProject,
+      openDraft,
       selectEnvironment,
       setSelectedModelKey,
       setWorkspaceMode,
@@ -1148,6 +1202,7 @@
       selectedProjectKey,
       selectedWorktreePath,
       setProject,
+      openDraft,
       selectBranch,
       selectEnvironment,
       setInteractionMode,
```

## apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts

Status: modified; +18; −0

```diff
@@ -230,6 +230,24 @@
     );
   });
 
+  it("reads an id-keyed new-task draft's environment from its project stamp", () => {
+    const stamped = {
+      project: {
+        environmentId,
+        projectId: "project" as never,
+        createdAt: "2026-09-05T00:00:00.000Z",
+      },
+    };
+    expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId);
+    // An id-keyed draft that lost its stamp belongs to nobody: uploads must
+    // not start and sign-out must not sweep it into some other environment.
+    expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull();
+    // The stamp wins over a legacy-looking key when both are present.
+    expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe(
+      environmentId,
+    );
+  });
+
   it("allows offline queuing while a connected composer waits for upload or retry", () => {
     const key = composerAttachmentUploadKey(environmentId, "file");
     const input = {
```

## apps/mobile/src/lib/composerAttachmentUploadQueue.ts

Status: modified; +17; −3

```diff
@@ -1,6 +1,7 @@
 import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
 import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
 
+import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key";
 import type { DraftComposerAttachment } from "./composerImages";
 
 export interface ComposerAttachmentUploadRequest {
@@ -20,12 +21,19 @@
   return `${environmentId}:${attachmentId}`;
 }
 
+/**
+ * Which environment a composer draft belongs to. Thread drafts carry it in
+ * the key; pending-task editor drafts borrow it from the queued message;
+ * new-task drafts carry it in their project stamp (legacy project-keyed
+ * new-task drafts still parse from the key until they are migrated on load).
+ */
 export function composerDraftEnvironmentId(
   draftKey: string,
   queuedMessages: ReadonlyArray<{
     readonly messageId: string;
     readonly environmentId: EnvironmentId;
   }>,
+  draft?: { readonly project?: { readonly environmentId: EnvironmentId } },
 ): EnvironmentId | null {
   if (draftKey.startsWith("pending-task:")) {
     return (
@@ -33,9 +41,15 @@
         ?.environmentId ?? null
     );
   }
-  const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey;
-  const separator = scope.lastIndexOf(":");
+  if (draftKey.startsWith("new-task:")) {
+    if (draft?.project) {
+      return draft.project.environmentId;
-  return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null;
+    }
+    const legacy = parseLegacyNewTaskDraftKey(draftKey);
+    return legacy === null ? null : EnvironmentId.make(legacy.environmentId);
+  }
+  const separator = draftKey.lastIndexOf(":");
+  return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null;
 }
 
 type UploadServerConfig = {
```

## apps/mobile/src/state/composer-attachment-uploads.ts

Status: modified; +2; −2

```diff
@@ -79,7 +79,7 @@
               let retained = false;
               for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) {
                 if (
-                  composerDraftEnvironmentId(key, queued) === environmentId &&
+                  composerDraftEnvironmentId(key, queued, draft) === environmentId &&
                   draft.attachments.some((candidate) => candidate.id === attachment.id)
                 ) {
                   retained = setComposerDraftAttachmentUpload(key, uploaded) || retained;
@@ -113,7 +113,7 @@
         .map((environment) => environment.environmentId),
     );
     const requests = Object.entries(drafts).flatMap(([key, draft]) => {
-      const environmentId = composerDraftEnvironmentId(key, queued);
+      const environmentId = composerDraftEnvironmentId(key, queued, draft);
       if (environmentId === null || !connected.has(environmentId)) return [];
       return draft.attachments
         .filter((attachment) =>
```

## apps/mobile/src/state/new-task-draft-key.ts

Status: added; +30; −0

```diff
@@ -1,0 +1,30 @@
+const NEW_TASK_DRAFT_PREFIX = "new-task:";
+
+/** Every new-task draft key: `new-task:<draftId>`. */
+export function newTaskDraftKey(draftId: string): string {
+  return `${NEW_TASK_DRAFT_PREFIX}${draftId}`;
+}
+
+export function isNewTaskDraftKey(draftKey: string): boolean {
+  return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX);
+}
+
+/**
+ * Builds before drafts were id-keyed used `new-task:<environmentId>:<projectId>`,
+ * one slot per project. Ids are UUIDs and never contain a colon, so a colon
+ * after the prefix marks the legacy shape. Returns the split scope, or null
+ * when the key is not legacy.
+ */
+export function parseLegacyNewTaskDraftKey(
+  draftKey: string,
+): { readonly environmentId: string; readonly projectId: string } | null {
+  if (!isNewTaskDraftKey(draftKey)) {
+    return null;
+  }
+  const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
+  const separator = scope.lastIndexOf(":");
+  if (separator <= 0 || separator === scope.length - 1) {
+    return null;
+  }
+  return { environmentId: scope.slice(0, separator), projectId: scope.slice(separator + 1) };
+}
```

## apps/mobile/src/state/pending-new-tasks-model.test.ts

Status: modified; +30; −36

```diff
@@ -3,11 +3,10 @@
 
 import type { QueuedThreadMessage } from "./thread-outbox-model";
 import type { ComposerDraft } from "./use-composer-drafts";
-import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model";
+import { buildPendingNewTasks } from "./pending-new-tasks-model";
 
 const environmentId = EnvironmentId.make("env-1");
 const projectId = ProjectId.make("project-1");
-const NOW = "2026-09-05T12:00:00.000Z";
 
 function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
   return {
@@ -27,62 +26,57 @@
   };
 }
 
+function draft(
+  text: string,
+  createdAt: string,
+  overrides: Partial<ComposerDraft> = {},
+): ComposerDraft {
+  return {
-function draft(text: string, overrides: Partial<ComposerDraft> = {}): ComposerDraft {
-  return { text, attachments: [], ...overrides };
+    text,
+    attachments: [],
+    project: { environmentId, projectId, createdAt },
+    ...overrides,
+  };
 }
 
-describe("parseNewTaskDraftKey", () => {
-  it("splits the environment and project ids", () => {
-    expect(parseNewTaskDraftKey(`new-task:${environmentId}:${projectId}`)).toEqual({
-      environmentId,
-      projectId,
-    });
-  });
-
-  it("ignores thread drafts and pending-task editor drafts", () => {
-    expect(parseNewTaskDraftKey(`${environmentId}:thread-1`)).toBeNull();
-    expect(parseNewTaskDraftKey("pending-task:message-1")).toBeNull();
-    expect(parseNewTaskDraftKey("new-task:")).toBeNull();
-    expect(parseNewTaskDraftKey("new-task:env-only")).toBeNull();
-  });
-});
-
 describe("buildPendingNewTasks", () => {
-  it("surfaces new-task drafts with content alongside queued creations", () => {
+  it("surfaces every new-task draft with content alongside queued creations", () => {
     const tasks = buildPendingNewTasks({
       queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")],
       drafts: {
+        "new-task:draft-old": draft("first idea", "2026-09-05T09:00:00.000Z", {
-        [`new-task:${environmentId}:${projectId}`]: draft("fix the offline outbox", {
           workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null },
         }),
+        "new-task:draft-new": draft("second idea", "2026-09-05T11:00:00.000Z"),
       },
-      now: NOW,
     });
 
     expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([
-      ["draft", "fix the offline outbox", "main"],
+      ["draft", "second idea", null],
+      ["draft", "first idea", "main"],
       ["pending", "queued a", "main"],
     ]);
+    expect(tasks[1]).toMatchObject({
-    expect(tasks[0]).toMatchObject({
-      key: `draft-task:new-task:${environmentId}:${projectId}`,
+      key: "draft-task:new-task:draft-old",
       environmentId,
       projectId,
-      draftKey: `new-task:${environmentId}:${projectId}`,
+      draftKey: "new-task:draft-old",
+      createdAt: "2026-09-05T09:00:00.000Z",
     });
   });
 
-  it("hides settings-only drafts and drafts for other surfaces", () => {
+  it("hides settings-only drafts, unstamped drafts, and drafts for other surfaces", () => {
     const tasks = buildPendingNewTasks({
       queuedMessages: [],
       drafts: {
-        [`new-task:${environmentId}:${projectId}`]: draft("", {
+        "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", {
           modelSelection: { instanceId: "codex" as never, model: "gpt" },
         }),
-        [`new-task:${environmentId}:${projectId}-2`]: draft("   "),
-        [`${environmentId}:thread-1`]: draft("thread composer text"),
-        "pending-task:message-1": draft("editor copy of a queued task"),
+        "new-task:blank": draft("   ", "2026-09-05T09:00:00.000Z"),
+        "new-task:unstamped": { text: "no project", attachments: [] },
+        [`${environmentId}:thread-1`]: { text: "thread composer text", attachments: [] },
+        "pending-task:message-1": { text: "editor copy of a queued task", attachments: [] },
       },
-      now: NOW,
     });
 
     expect(tasks).toEqual([]);
@@ -102,9 +96,10 @@
     const tasks = buildPendingNewTasks({
       queuedMessages: [],
       drafts: {
-        [`new-task:${environmentId}:${projectId}`]: draft("", { attachments: [attachment] }),
+        "new-task:with-image": draft("", "2026-09-05T09:00:00.000Z", {
+          attachments: [attachment],
+        }),
       },
-      now: NOW,
     });
 
     expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]);
@@ -118,7 +113,6 @@
         queuedCreation("new", "2026-09-05T10:00:00.000Z"),
       ],
       drafts: {},
-      now: NOW,
     });
 
     expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]);
```

## apps/mobile/src/state/pending-new-tasks-model.ts

Status: modified; +11; −34

```diff
@@ -1,15 +1,16 @@
-import { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
 
 import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
 import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model";
+import { isNewTaskDraftKey } from "./new-task-draft-key";
 import type { ComposerDraft } from "./use-composer-drafts";
 
 /**
  * Unsent work that will become a thread, shaped for thread-list presentation.
  * A `pending` task sits in the outbox and sends itself when its environment
- * reconnects; a `draft` is the project's new-task composer content, which
- * only sends when the user submits it. Both share the list slot so the user
- * can find everything they have written but not yet started in one place.
+ * reconnects; a `draft` is new-task composer content, which only sends when
+ * the user submits it. Both share the list slot so the user can find
+ * everything they have written but not yet started in one place.
  */
 export type PendingNewTask = PendingQueuedTask | PendingDraftTask;
 
@@ -36,32 +37,11 @@
   readonly projectCwd: undefined;
   readonly branch: string | null;
   readonly title: string;
-  /** Drafts have no creation timestamp; they sort as current work. */
   readonly createdAt: string;
   readonly draftKey: string;
   readonly draft: ComposerDraft;
 }
 
-const NEW_TASK_DRAFT_PREFIX = "new-task:";
-
-/** Parses a `new-task:<environmentId>:<projectId>` composer draft key. */
-export function parseNewTaskDraftKey(
-  draftKey: string,
-): { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } | null {
-  if (!draftKey.startsWith(NEW_TASK_DRAFT_PREFIX)) {
-    return null;
-  }
-  const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
-  const separator = scope.lastIndexOf(":");
-  if (separator <= 0 || separator === scope.length - 1) {
-    return null;
-  }
-  return {
-    environmentId: EnvironmentId.make(scope.slice(0, separator)),
-    projectId: ProjectId.make(scope.slice(separator + 1)),
-  };
-}
-
 /**
  * Settings-only drafts (a model pick with no text) are not work the user
  * would look for in the list; only text or attachments make a draft visible.
@@ -81,8 +61,6 @@
 export function buildPendingNewTasks(input: {
   readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
   readonly drafts: Readonly<Record<string, ComposerDraft>>;
-  /** ISO timestamp drafts sort by; they carry no creation time of their own. */
-  readonly now: string;
 }): ReadonlyArray<PendingNewTask> {
   const tasks: PendingNewTask[] = [];
   for (const message of input.queuedMessages) {
@@ -104,26 +82,25 @@
     });
   }
   for (const [draftKey, draft] of Object.entries(input.drafts)) {
-    const ref = parseNewTaskDraftKey(draftKey);
-    if (ref === null || !composerDraftHasUserContent(draft)) {
+    if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) {
       continue;
     }
     tasks.push({
       kind: "draft",
       key: `draft-task:${draftKey}`,
-      environmentId: ref.environmentId,
-      projectId: ref.projectId,
+      environmentId: draft.project.environmentId,
+      projectId: draft.project.projectId,
       projectTitle: undefined,
       projectCwd: undefined,
       branch: draft.workspaceSelection?.branch ?? null,
       title: draftTitle(draft),
+      createdAt: draft.project.createdAt,
-      createdAt: input.now,
       draftKey,
       draft,
     });
   }
+  // Drafts are what the user is writing now, so they lead; within each kind,
+  // newest first.
-  // Drafts are what the user is writing now, so they lead; queued tasks
-  // follow newest-first.
   tasks.sort((left, right) => {
     if (left.kind !== right.kind) {
       return left.kind === "draft" ? -1 : 1;
```

## apps/mobile/src/state/use-composer-drafts.test.ts

Status: modified; +203; −120

```diff
@@ -3,6 +3,7 @@
   CommandId,
   EnvironmentId,
   MessageId,
+  ProjectId,
   ProviderInstanceId,
   ThreadId,
 } from "@t3tools/contracts";
@@ -157,20 +158,22 @@
   ComposerDraftPersistenceError,
   composerDraftsAtom,
   composerCloudDraftsAtom,
-  copyComposerDraftContentIfEmpty,
-  copyComposerDraftContentState,
+  createNewTaskDraft,
   decodePersistedComposerState,
   ensureComposerDraftsLoaded,
   type ComposerDraft,
+  findNewTaskDraftKeys,
   flushComposerDrafts,
   getComposerDraftSnapshot,
   mergeComposerDraftContentState,
+  migrateLegacyNewTaskDraft,
   releaseUnusedComposerAttachmentFiles,
   removeComposerDraftsForEnvironment,
   resetComposerDraftsLoadState,
   retainComposerAttachmentFileForPreview,
   restoreComposerDraftSnapshotState,
   restoreCloudComposerDrafts,
+  retargetNewTaskDraft,
   setComposerDraftText,
   setComposerDraftAttachmentUpload,
   waitForComposerDraftsLoaded,
@@ -210,37 +213,6 @@
 describe("mobile composer drafts", () => {
   // Hydration is one-shot per module instance and the attachment sweep now
   // triggers it too, so this test must observe it before any sweep test runs.
-  it("waits for persisted drafts before copying content between projects", async () => {
-    const sourceKey = "new-task:environment-1:project-1";
-    const targetKey = "new-task:environment-1:project-2";
-    const unrelatedKey = "environment-1:thread-1";
-    const source = { text: "Current task", attachments: [] } satisfies ComposerDraft;
-    const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft;
-    const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft;
-
-    composerDraftFileMocks.setDocument({
-      schemaVersion: 1,
-      drafts: {
-        [targetKey]: target,
-        [unrelatedKey]: unrelated,
-      },
-    });
-    composerDraftFileMocks.blockRead();
-    appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source });
-
-    const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey);
-    expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source });
-
-    composerDraftFileMocks.releaseRead();
-    await copy;
-
-    expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
-      [sourceKey]: source,
-      [targetKey]: target,
-      [unrelatedKey]: unrelated,
-    });
-  });
-
   it("hydrates generic file attachments from their saved local paths", () => {
     const file = {
       id: "file-1",
@@ -1009,7 +981,7 @@
   });
 
   it("hydrates selector state even when the message content is empty", () => {
+    const hydrated = Object.entries(
-    expect(
       decodePersistedComposerState({
         schemaVersion: 1,
         drafts: {
@@ -1031,26 +1003,33 @@
           },
         },
       }).drafts,
+    );
-    ).toEqual({
-      "new-task:environment-1:project-1": {
-        text: "",
-        attachments: [],
-        modelSelection: {
-          instanceId: "codex",
-          model: "gpt-5.4",
-          options: [{ id: "reasoningEffort", value: "xhigh" }],
-        },
-        runtimeMode: "approval-required",
-        interactionMode: "plan",
-        workspaceSelection: {
-          mode: "worktree",
-          branch: "main",
-          worktreePath: null,
+    expect(hydrated).toHaveLength(1);
+    const [key, draft] = hydrated[0]!;
+    // Legacy project keys are rewritten to id keys on load.
-        },
+    expect(key).toMatch(/^new-task:[0-9a-z-]+$/);
+    expect(draft).toEqual({
+      text: "",
+      attachments: [],
+      modelSelection: {
+        instanceId: "codex",
+        model: "gpt-5.4",
+        options: [{ id: "reasoningEffort", value: "xhigh" }],
       },
+      runtimeMode: "approval-required",
+      interactionMode: "plan",
+      workspaceSelection: {
+        mode: "worktree",
+        branch: "main",
+        worktreePath: null,
+      },
+      project: {
+        environmentId: "environment-1",
+        projectId: "project-1",
+        createdAt: expect.any(String),
+      },
     });
   });
-
   it("keeps legacy content-only drafts and rejects invalid selector state", () => {
     expect(
       decodePersistedComposerState({
@@ -1085,7 +1064,7 @@
     // The stale-model strip must not touch receipt-bearing drafts, and the
     // empty filter must keep them — or the same share would re-import after
     // restart.
-    expect(
+    const stripped = Object.values(
       decodePersistedComposerState({
         schemaVersion: 1,
         drafts: {
@@ -1098,20 +1077,146 @@
           },
         },
       }).drafts,
+    );
+    expect(stripped).toHaveLength(1);
+    expect(stripped[0]).toMatchObject({
+      text: "",
+      attachments: [],
+      importedShareIds: ["share-1"],
+      project: { environmentId: "environment-1", projectId: "project-1" },
-    ).toEqual({
-      "new-task:environment-1:project-1": {
-        text: "",
-        attachments: [],
-        importedShareIds: ["share-1"],
-      },
     });
+    expect(stripped[0]?.modelSelection).toBeUndefined();
 
-    expect(
+    const kept = Object.values(
       decodePersistedComposerState({
         schemaVersion: 1,
         drafts: { "new-task:environment-1:project-1": receiptDraft },
       }).drafts,
+    );
+    expect(kept).toHaveLength(1);
+    expect(kept[0]).toMatchObject(receiptDraft);
+  });
+
+  it("migrates archived signed-out new-task drafts the same way as live ones", () => {
+    const decoded = decodePersistedComposerState({
+      schemaVersion: 1,
+      drafts: {},
+      cloudAccountId: "account-1",
+      signedOutDrafts: {
+        "account-1": {
+          drafts: { "new-task:environment-1:project-1": { text: "archived", attachments: [] } },
+          queuedMessages: [],
+        },
+      },
+    });
+    const archived = Object.entries(decoded.cloudDrafts.signedOut["account-1"]?.drafts ?? {});
+    expect(archived).toHaveLength(1);
+    expect(archived[0]?.[0]).toMatch(/^new-task:[0-9a-z]+-[0-9a-z]+$/);
+    expect(archived[0]?.[1]).toMatchObject({
+      text: "archived",
+      project: { environmentId: "environment-1", projectId: "project-1" },
+    });
+  });
+
+  it("migrates project-keyed new-task drafts to id keys with the project stamped in", () => {
+    const now = "2026-09-05T12:00:00.000Z";
+    const [key, draft] = migrateLegacyNewTaskDraft(
+      "new-task:environment-1:project-1",
+      { text: "keep me", attachments: [] },
+      now,
+    );
+    // The new key has no colon after the prefix, so it can never be
+    // mistaken for the legacy shape on the next load.
+    expect(key).toMatch(/^new-task:[0-9a-z-]+$/);
+    expect(draft).toEqual({
+      text: "keep me",
+      attachments: [],
+      project: {
+        environmentId: EnvironmentId.make("environment-1"),
+        projectId: ProjectId.make("project-1"),
+        createdAt: now,
+      },
+    });
+
+    // Already-migrated, thread, and pending-task keys pass through untouched.
+    const stamped: ComposerDraft = {
+      text: "x",
+      attachments: [],
+      project: {
+        environmentId: EnvironmentId.make("environment-1"),
+        projectId: ProjectId.make("project-1"),
+        createdAt: now,
+      },
+    };
+    expect(migrateLegacyNewTaskDraft("new-task:some-id", stamped, now)).toEqual([
+      "new-task:some-id",
+      stamped,
+    ]);
+    expect(migrateLegacyNewTaskDraft("environment-1:thread-1", DRAFT, now)).toEqual([
+      "environment-1:thread-1",
+      DRAFT,
+    ]);
+    expect(migrateLegacyNewTaskDraft("pending-task:message-1", DRAFT, now)).toEqual([
+      "pending-task:message-1",
+      DRAFT,
+    ]);
+  });
+
+  it("keeps a freshly minted new-task draft bound until content arrives, then lists it per project", () => {
+    const project = {
+      environmentId: EnvironmentId.make("environment-1"),
+      projectId: ProjectId.make("project-1"),
+    };
+    const first = createNewTaskDraft(project);
+    const second = createNewTaskDraft(project);
+    expect(first).not.toBe(second);
+    // Empty stamped drafts stay in memory so the composer has a key to write
+    // to, but the persisted document leaves them out.
+    expect(appAtomRegistry.get(composerDraftsAtom)[first]?.project).toMatchObject(project);
+
+    setComposerDraftText(first, "first idea");
+    setComposerDraftText(second, "second idea");
+    expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), project)).toEqual(
+      expect.arrayContaining([first, second]),
+    );
+
+    // Clearing content on the way out drops the stamp with it.
+    clearComposerDraftContent(first, { clearModelSelection: true, clearWorkspaceSelection: true });
+    expect(appAtomRegistry.get(composerDraftsAtom)[first]).toBeUndefined();
+    expect(getComposerDraftSnapshot(second).text).toBe("second idea");
+  });
+
+  it("retargets a new-task draft to another project without losing its text", () => {
+    const from = {
+      environmentId: EnvironmentId.make("environment-1"),
+      projectId: ProjectId.make("project-1"),
+    };
+    const to = {
+      environmentId: EnvironmentId.make("environment-2"),
+      projectId: ProjectId.make("project-2"),
+    };
+    const key = createNewTaskDraft(from);
+    setComposerDraftText(key, "moving house");
+    appAtomRegistry.set(composerDraftsAtom, {
+      ...appAtomRegistry.get(composerDraftsAtom),
-    ).toEqual({ "new-task:environment-1:project-1": receiptDraft });
+      [key]: {
+        ...getComposerDraftSnapshot(key),
+        runtimeMode: "approval-required",
+        workspaceSelection: { mode: "worktree", branch: "feature/a", worktreePath: null },
+      },
+    });
+    const createdAt = getComposerDraftSnapshot(key).project?.createdAt;
+
+    retargetNewTaskDraft(key, to);
+
+    const moved = getComposerDraftSnapshot(key);
+    expect(moved.text).toBe("moving house");
+    expect(moved.runtimeMode).toBe("approval-required");
+    // Branch and worktree belong to the old repo.
+    expect(moved.workspaceSelection).toBeUndefined();
+    expect(moved.project).toEqual({ ...to, createdAt });
+    expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]);
+    expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]);
   });
 
   it("hydrates the global sticky model selection", () => {
@@ -1387,56 +1492,7 @@
     expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
   });
 
-  it("carries unfinished content to a newly selected project without overwriting its settings", () => {
-    const sourceKey = "new-task:environment-1:project-1";
-    const targetKey = "new-task:environment-1:project-2";
-    const source: ComposerDraft = {
-      text: "Keep this task",
-      attachments: [],
-      importedShareIds: ["share-1"],
-      workspaceSelection: {
-        mode: "worktree",
-        branch: "feature/source",
-        worktreePath: null,
-      },
-    };
-    const target: ComposerDraft = {
+  it("drops another environment's upload stamp when a draft moves across machines", () => {
-      text: "",
-      attachments: [],
-      runtimeMode: "approval-required",
-    };
-
-    expect(
-      copyComposerDraftContentState(
-        { [sourceKey]: source, [targetKey]: target },
-        sourceKey,
-        targetKey,
-      ),
-    ).toEqual({
-      [sourceKey]: source,
-      [targetKey]: {
-        ...target,
-        text: source.text,
-        attachments: source.attachments,
-        importedShareIds: source.importedShareIds,
-      },
-    });
-  });
-
-  it("does not overwrite unfinished content already stored for the selected project", () => {
-    const sourceKey = "new-task:environment-1:project-1";
-    const targetKey = "new-task:environment-1:project-2";
-    const drafts: Record<string, ComposerDraft> = {
-      [sourceKey]: { text: "Source task", attachments: [] },
-      [targetKey]: { text: "Target task", attachments: [] },
-    };
-
-    expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts);
-  });
-
-  it("drops another environment's upload stamp when carrying attachments across machines", () => {
-    const sourceKey = "new-task:environment-1:project-1";
-    const targetKey = "new-task:environment-2:project-2";
     const uploadedElsewhere: DraftComposerAttachment = {
       id: "image-1",
       type: "image",
@@ -1454,14 +1510,25 @@
       uploadedAttachmentId: "upload-2",
       uploadEnvironmentId: EnvironmentId.make("environment-2"),
     };
+    const key = createNewTaskDraft({
+      environmentId: EnvironmentId.make("environment-1"),
+      projectId: ProjectId.make("project-1"),
+    });
+    appAtomRegistry.set(composerDraftsAtom, {
+      ...appAtomRegistry.get(composerDraftsAtom),
+      [key]: {
+        ...getComposerDraftSnapshot(key),
+        text: "Ship it",
+        attachments: [uploadedElsewhere, uploadedOnTarget],
+      },
+    });
 
-    const next = copyComposerDraftContentState(
-      { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } },
-      sourceKey,
-      targetKey,
-    );
+    retargetNewTaskDraft(key, {
+      environmentId: EnvironmentId.make("environment-2"),
+      projectId: ProjectId.make("project-2"),
+    });
 
-    expect(next[targetKey]?.attachments).toEqual([
+    expect(getComposerDraftSnapshot(key).attachments).toEqual([
       {
         id: "image-1",
         type: "image",
@@ -1473,7 +1540,6 @@
       },
       uploadedOnTarget,
     ]);
-    expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]);
   });
 
   it("merges shared content into a project draft without duplicating retries", () => {
@@ -1564,19 +1630,36 @@
     const environmentId = EnvironmentId.make("environment-cloud");
     const retainedEnvironmentId = EnvironmentId.make("environment-local");
 
+    const cloudDraft: ComposerDraft = {
+      ...DRAFT,
+      project: {
+        environmentId,
+        projectId: ProjectId.make("project-cloud"),
+        createdAt: "2026-09-05T00:00:00.000Z",
+      },
+    };
+    const localDraft: ComposerDraft = {
+      ...DRAFT,
+      project: {
+        environmentId: retainedEnvironmentId,
+        projectId: ProjectId.make("project-local"),
+        createdAt: "2026-09-05T00:00:00.000Z",
+      },
+    };
+
     expect(
       removeComposerDraftsForEnvironment(
         {
           [`${environmentId}:thread-cloud`]: DRAFT,
-          [`new-task:${environmentId}:project-cloud`]: DRAFT,
+          "new-task:cloud-draft": cloudDraft,
           [`${retainedEnvironmentId}:thread-local`]: DRAFT,
+          "new-task:local-draft": localDraft,
-          [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
         },
         environmentId,
       ),
     ).toEqual({
       [`${retainedEnvironmentId}:thread-local`]: DRAFT,
-      [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
+      "new-task:local-draft": localDraft,
     });
   });
 
```

## apps/mobile/src/state/use-composer-drafts.ts

Status: modified; +204; −121

```diff
@@ -1,11 +1,14 @@
 import { useAtomValue } from "@effect/atom-react";
 import {
+  EnvironmentId as EnvironmentIdSchema,
   ModelSelection as ModelSelectionSchema,
   PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
+  ProjectId as ProjectIdSchema,
   ProviderInteractionMode as ProviderInteractionModeSchema,
   RuntimeMode as RuntimeModeSchema,
   type EnvironmentId,
   type ModelSelection,
+  type ProjectId,
   type ProviderInteractionMode,
   type RuntimeMode,
 } from "@t3tools/contracts";
@@ -24,6 +27,11 @@
 import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
 import { appAtomRegistry } from "./atom-registry";
 import {
+  isNewTaskDraftKey,
+  newTaskDraftKey,
+  parseLegacyNewTaskDraftKey,
+} from "./new-task-draft-key";
+import {
   decodeQueuedThreadMessage,
   encodeQueuedThreadMessage,
   QueuedThreadMessageSchema,
@@ -59,8 +67,20 @@
   readonly runtimeMode?: RuntimeMode;
   readonly interactionMode?: ProviderInteractionMode;
   readonly workspaceSelection?: ComposerDraftWorkspaceSelection;
+  /**
+   * Set on new-task drafts only. The project is stored here rather than in
+   * the key so a project can hold any number of drafts and a draft can be
+   * retargeted to another project without changing identity.
+   */
+  readonly project?: ComposerDraftProject;
 }
 
+export interface ComposerDraftProject {
+  readonly environmentId: EnvironmentId;
+  readonly projectId: ProjectId;
+  readonly createdAt: string;
+}
+
 export interface ComposerDraftContent {
   readonly text: string;
   readonly attachments: ReadonlyArray<DraftComposerAttachment>;
@@ -76,7 +96,7 @@
 
 export type ComposerDraftSettingsUpdate = Pick<
   ComposerDraft,
+  "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project"
-  "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection"
 >;
 
 const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
@@ -86,6 +106,12 @@
   startFromOrigin: Schema.optional(Schema.Boolean),
 });
 
+const ComposerDraftProjectSchema = Schema.Struct({
+  environmentId: EnvironmentIdSchema,
+  projectId: ProjectIdSchema,
+  createdAt: Schema.String,
+});
+
 const ComposerDraftSchema = Schema.Struct({
   text: Schema.String,
   attachments: Schema.Array(DraftComposerAttachmentSchema),
@@ -94,6 +120,7 @@
   runtimeMode: Schema.optional(RuntimeModeSchema),
   interactionMode: Schema.optional(ProviderInteractionModeSchema),
   workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),
+  project: Schema.optional(ComposerDraftProjectSchema),
 });
 
 const PersistedComposerDraftsSchema = Schema.Struct({
@@ -176,6 +203,8 @@
   return isEmptyDraft(draft);
 }
 
+// The project stamp is identity, not content: a new-task draft with nothing
+// else in it is still empty and gets dropped like any other.
 function isEmptyDraft(draft: ComposerDraft): boolean {
   return (
     draft.text.length === 0 &&
@@ -187,35 +216,90 @@
   );
 }
 
+/**
+ * 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]) =>
+          migrateLegacyNewTaskDraft(
+            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
-        .map(
-          ([key, draft]) =>
-            [
-              key,
-              // Stale new-task drafts left on disk by builds before the
+            // with runtime/interaction/workspace settings or actual text /
+            // attachments were deliberately configured and are left alone.
+            isNewTaskDraftKey(key) &&
-              // 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:") &&
               draft.modelSelection &&
               draft.text.length === 0 &&
               draft.attachments.length === 0 &&
               draft.runtimeMode === undefined &&
               draft.interactionMode === undefined &&
               draft.workspaceSelection === undefined
-                ? { ...draft, modelSelection: undefined }
+              ? { ...draft, modelSelection: undefined }
+              : draft,
+            now,
-                : draft,
-            ] as const,
+          ),
         )
         // importedShareIds are share-import receipts: a contentless draft
         // carrying one is not empty, or the same native share would be
@@ -229,7 +313,13 @@
         Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [
           id,
           {
+            // Archived drafts come back through restoreCloudComposerDrafts
+            // without another decode, so they get the same key migration.
-            drafts: saved.drafts,
+            drafts: Object.fromEntries(
+              Object.entries(saved.drafts).map(([key, draft]) =>
+                migrateLegacyNewTaskDraft(key, draft, now),
+              ),
+            ),
             queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage),
           },
         ]),
@@ -622,7 +712,7 @@
   const remaining = { ...current };
   const savedDrafts = { ...cloud.signedOut[owner]?.drafts };
   for (const [key, draft] of Object.entries(current)) {
+    const environmentId = composerDraftEnvironmentId(key, queued, draft);
-    const environmentId = composerDraftEnvironmentId(key, queued);
     if (environmentId !== null && environmentIds.has(environmentId)) {
       savedDrafts[key] = draft;
       delete remaining[key];
@@ -807,16 +897,8 @@
     const draft = {
       ...normalizeDraft(current[draftKey]),
       text: value,
-    };
-    if (isEmptyDraft(draft)) {
-      const next = { ...current };
-      delete next[draftKey];
-      return next;
-    }
-    return {
-      ...current,
-      [draftKey]: draft,
     };
+    return withComposerDraft(current, draftKey, draft);
   });
 }
 
@@ -880,16 +962,8 @@
     const draft = {
       ...normalizeDraft(current[draftKey]),
       attachments,
-    };
-    if (isEmptyDraft(draft)) {
-      const next = { ...current };
-      delete next[draftKey];
-      return next;
-    }
-    return {
-      ...current,
-      [draftKey]: draft,
     };
+    return withComposerDraft(current, draftKey, draft);
   });
   const retainedIds = new Set(attachments.map((attachment) => attachment.id));
   scheduleUnusedComposerAttachmentCleanup(
@@ -904,16 +978,8 @@
     const draft = {
       ...existing,
       attachments: existing.attachments.filter((image) => image.id !== imageId),
-    };
-    if (isEmptyDraft(draft)) {
-      const next = { ...current };
-      delete next[draftKey];
-      return next;
-    }
-    return {
-      ...current,
-      [draftKey]: draft,
     };
+    return withComposerDraft(current, draftKey, draft);
   });
   scheduleUnusedComposerAttachmentCleanup(
     previousAttachments.filter((attachment) => attachment.id === imageId),
@@ -964,15 +1030,7 @@
       ...normalizeDraft(current[draftKey]),
       ...settings,
     };
-    if (isEmptyDraft(draft)) {
-      const next = { ...current };
-      delete next[draftKey];
-      return next;
-    }
+    return withComposerDraft(current, draftKey, draft);
-    return {
-      ...current,
-      [draftKey]: draft,
-    };
   });
 }
 
@@ -988,10 +1046,14 @@
   if (!existing) {
     return current;
   }
+  // Clearing content is the "this draft is done" moment (sent, queued, or
+  // discarded), so the project stamp goes too and an otherwise-empty new-task
+  // draft leaves the store rather than lingering as a blank row.
   const {
     importedShareIds: _importedShareIds,
     modelSelection,
     workspaceSelection,
+    project: _project,
     ...retained
   } = existing;
   const draft = {
@@ -1028,67 +1090,11 @@
   return next;
 }
 
-export function copyComposerDraftContentState(
-  current: Record<string, ComposerDraft>,
-  sourceDraftKey: string,
-  targetDraftKey: string,
-): Record<string, ComposerDraft> {
-  if (sourceDraftKey === targetDraftKey) {
-    return current;
-  }
-  const source = normalizeDraft(current[sourceDraftKey]);
-  const target = normalizeDraft(current[targetDraftKey]);
-  const sourceHasContent =
-    source.text.length > 0 ||
-    source.attachments.length > 0 ||
-    (source.importedShareIds?.length ?? 0) > 0;
-  const targetHasContent =
-    target.text.length > 0 ||
-    target.attachments.length > 0 ||
-    (target.importedShareIds?.length ?? 0) > 0;
-  if (!sourceHasContent || targetHasContent) {
-    return current;
-  }
-  // Pending uploads live on one server. Crossing environments keeps the local
-  // bytes (the upload worker re-sends them to the new key's environment) but
-  // drops the old stamp, so it cannot pin the source environment's pending
-  // upload alive from the copy.
-  const targetEnvironmentId = composerDraftEnvironmentId(targetDraftKey, []);
-  const attachments = source.attachments.map((attachment) =>
-    attachment.uploadEnvironmentId !== undefined &&
-    attachment.uploadEnvironmentId !== targetEnvironmentId
-      ? stripAttachmentUploadReference(attachment)
-      : attachment,
-  );
-  return {
-    ...current,
-    [targetDraftKey]: {
-      ...target,
-      text: source.text,
-      attachments,
-      ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}),
-    },
-  };
-}
-
 function stripAttachmentUploadReference(
   attachment: DraftComposerAttachment,
 ): DraftComposerAttachment {
   const { uploadedAttachmentId: _id, uploadEnvironmentId: _environmentId, ...rest } = attachment;
   return rest;
-}
-
-export async function copyComposerDraftContentIfEmpty(
-  sourceDraftKey: string,
-  targetDraftKey: string,
-): Promise<void> {
-  ensureComposerDraftsLoaded();
-  if (loadPromise !== null) {
-    await loadPromise;
-  }
-  updateComposerDrafts((current) =>
-    copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey),
-  );
 }
 
 function mergeComposerDraftText(existing: string, incoming: string): string {
@@ -1275,15 +1281,7 @@
     interactionMode: undoSetting("interactionMode"),
     workspaceSelection: undoSetting("workspaceSelection"),
   };
-  if (isEmptyDraft(draft)) {
-    const next = { ...current };
-    delete next[draftKey];
-    return next;
-  }
-  return {
+  return withComposerDraft(current, draftKey, draft);
-    ...current,
-    [draftKey]: draft,
-  };
 }
 
 /** Applies undoComposerDraftMergeState and lands it durably. */
@@ -1355,15 +1353,100 @@
   environmentId: EnvironmentId,
 ): Record<string, ComposerDraft> {
   const environmentPrefix = `${environmentId}:`;
-  const newTaskPrefix = `new-task:${environmentId}:`;
   return Object.fromEntries(
     Object.entries(drafts).filter(
+      ([draftKey, draft]) =>
+        !draftKey.startsWith(environmentPrefix) && draft.project?.environmentId !== environmentId,
-      ([draftKey]) =>
-        !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix),
     ),
   );
 }
 
+/**
+ * Mints a new-task draft for a project. The entry is published immediately so
+ * the composer can bind to its key before the user types; it stays out of the
+ * list until it has content, and the empty-draft sweep drops it on persist if
+ * nothing is ever written.
+ */
+export function createNewTaskDraft(project: {
+  readonly environmentId: EnvironmentId;
+  readonly projectId: ProjectId;
+}): string {
+  const draftKey = newTaskDraftKey(newDraftId());
+  const stamp: ComposerDraftProject = {
+    environmentId: project.environmentId,
+    projectId: project.projectId,
+    createdAt: new Date().toISOString(),
+  };
+  updateComposerDrafts((current) => ({
+    ...current,
+    [draftKey]: { ...EMPTY_DRAFT, project: stamp },
+  }));
+  return draftKey;
+}
+
+/**
+ * Points an existing new-task draft at a different project, keeping its
+ * content and identity. Workspace selection is project-specific (branch,
+ * worktree), so it is cleared; model and mode choices carry over.
+ */
+export function retargetNewTaskDraft(
+  draftKey: string,
+  project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
+): void {
+  updateComposerDrafts((current) => {
+    const existing = current[draftKey];
+    const stamp = existing?.project;
+    if (
+      stamp !== undefined &&
+      stamp.environmentId === project.environmentId &&
+      stamp.projectId === project.projectId
+    ) {
+      return current;
+    }
+    const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing);
+    // Pending uploads live on one server. Crossing environments keeps the
+    // local bytes (the upload worker re-sends them to the new environment)
+    // but drops the old stamp, so it cannot pin the source environment's
+    // pending upload alive from the moved draft.
+    const attachments = retained.attachments.map((attachment) =>
+      attachment.uploadEnvironmentId !== undefined &&
+      attachment.uploadEnvironmentId !== project.environmentId
+        ? stripAttachmentUploadReference(attachment)
+        : attachment,
+    );
+    return {
+      ...current,
+      [draftKey]: {
+        ...retained,
+        attachments,
+        project: {
+          environmentId: project.environmentId,
+          projectId: project.projectId,
+          createdAt: stamp?.createdAt ?? new Date().toISOString(),
+        },
+      },
+    };
+  });
+}
+
+/** New-task drafts for a project, newest first. */
+export function findNewTaskDraftKeys(
+  drafts: Readonly<Record<string, ComposerDraft>>,
+  project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
+): ReadonlyArray<string> {
+  return Object.entries(drafts)
+    .filter(
+      ([key, draft]) =>
+        isNewTaskDraftKey(key) &&
+        draft.project?.environmentId === project.environmentId &&
+        draft.project.projectId === project.projectId,
+    )
+    .sort(([, left], [, right]) =>
+      (right.project?.createdAt ?? "").localeCompare(left.project?.createdAt ?? ""),
+    )
+    .map(([key]) => key);
+}
+
 export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise<void> {
   ensureComposerDraftsLoaded();
   if (loadPromise !== null) {
```

## apps/mobile/src/state/use-pending-new-tasks.ts

Status: modified; +0; −3

```diff
@@ -20,9 +20,6 @@
       buildPendingNewTasks({
         queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey),
         drafts,
-        // Stamped when the inputs change, not per render, so a draft keeps one
-        // sort position while the user is not typing in it.
-        now: new Date().toISOString(),
       }),
     [queuedMessagesByThreadKey, drafts],
   );
```

## apps/mobile/src/state/use-thread-outbox-drain.test.ts

Status: modified; +9; −4

```diff
@@ -582,7 +582,7 @@
 });
 
 describe("thread outbox recovery rollback", () => {
-  it("restores a rejected new task into its durable project draft", async () => {
+  it("restores a rejected new task as its own draft for the project", async () => {
     const message: QueuedThreadMessage = {
       ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }),
       modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
@@ -599,14 +599,19 @@
       "restored",
     );
 
+    // The draft is keyed by the message so a retry lands on the same one, and
+    // stamped with the project so it shows up as a Draft row for that project.
     expect(
-      composerDrafts.getComposerDraftSnapshot(
+      composerDrafts.getComposerDraftSnapshot(`new-task:restored-${message.messageId}`),
-        `new-task:${message.environmentId}:${message.creation!.projectId}`,
-      ),
     ).toMatchObject({
       text: message.text,
       attachments: message.attachments,
       modelSelection: message.modelSelection,
+      project: {
+        environmentId: message.environmentId,
+        projectId: message.creation!.projectId,
+        createdAt: message.createdAt,
+      },
     });
     expect(remainingMessages()).toEqual([]);
     expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server");
```

## apps/mobile/src/state/use-thread-outbox-drain.ts

Status: modified; +23; −2

```diff
@@ -17,7 +17,7 @@
 import { useCallback, useEffect, useRef, useState } from "react";
 import { Alert } from "react-native";
 
-import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
+import { scopedThreadKey } from "../lib/scopedEntities";
 import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
 import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
 import { randomHex } from "../lib/uuid";
@@ -53,6 +53,7 @@
   type ComposerDraft,
   getComposerDraftSnapshot,
   mergeComposerDraftContent,
+  newTaskDraftKey,
   replaceComposerDraftAttachments,
   removeDeliveredCloudQueuedMessage,
   undoComposerDraftMerge,
@@ -369,6 +370,7 @@
 
     let mergedDraft: ComposerDraft;
     try {
+      stampRecoveryDraftProject(queuedMessage, draftKey);
       await mergeComposerDraftContent(draftKey, {
         text: queuedMessage.text,
         attachments: queuedMessage.attachments,
@@ -451,12 +453,31 @@
   }
 }
 
+/**
+ * A rejected creation becomes its own new-task draft rather than merging into
+ * whatever the user is typing for that project. The key derives from the
+ * message id so a retry after a mid-recovery failure lands on the same draft
+ * instead of minting another.
+ */
 function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string {
   return queuedMessage.creation
-    ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}`
+    ? newTaskDraftKey(`restored-${queuedMessage.messageId}`)
     : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);
 }
 
+function stampRecoveryDraftProject(queuedMessage: QueuedThreadMessage, draftKey: string): void {
+  if (!queuedMessage.creation) {
+    return;
+  }
+  updateComposerDraftSettings(draftKey, {
+    project: {
+      environmentId: queuedMessage.environmentId,
+      projectId: queuedMessage.creation.projectId,
+      createdAt: queuedMessage.createdAt,
+    },
+  });
+}
+
 async function preserveUploadedAttachmentsForEditor(
   originalMessage: QueuedThreadMessage,
   uploadedMessage: QueuedThreadMessage,
```
