diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts
index e87df9dc243e..dcf127ccae20 100644
--- a/apps/mobile/src/features/home/usePendingTaskListActions.ts
+++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts
@@ -15,8 +15,6 @@ export function usePendingTaskListActions(): {
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 @@ export function usePendingTaskListActions(): {
projectId: String(pendingTask.projectId),
...(pendingTask.kind === "pending"
? { pendingTaskId: String(pendingTask.message.messageId) }
- : {}),
+ : { draftId: pendingTask.draftKey }),
},
});
},
diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
index 8e6819378a6d..dc1ee942d13a 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
@@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = {
readonly projectId?: string | string[];
readonly title?: string | string[];
readonly pendingTaskId?: string | string[];
+ readonly draftId?: string | string[];
readonly incomingShareId?: string | string[];
};
@@ -43,6 +44,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps
>
);
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index a50895bd33da..e2afdbb498a4 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -83,6 +83,7 @@ import {
restoreComposerDraftSnapshot,
scheduleUnusedComposerAttachmentCleanup,
type ComposerDraft,
+ waitForComposerDraftsLoaded,
} from "../../state/use-composer-drafts";
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import {
@@ -150,6 +151,8 @@ export function NewTaskDraftScreen(props: {
};
/** 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 @@ 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.
+ // 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(null);
+ useEffect(() => {
+ if (!props.draftId || props.pendingTaskId) {
+ return;
+ }
+ const draftId = props.draftId;
+ if (attemptedDraftIdRef.current === draftId) {
+ return;
+ }
+ let cancelled = false;
+ void waitForComposerDraftsLoaded().then(() => {
+ if (cancelled || attemptedDraftIdRef.current === draftId) {
+ return;
+ }
+ if (openDraft(draftId)) {
+ attemptedDraftIdRef.current = draftId;
+ return;
+ }
+ if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) {
+ // 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(null);
useEffect(() => {
if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) {
@@ -457,9 +497,10 @@ export function NewTaskDraftScreen(props: {
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).
- if (props.pendingTaskId) {
+ // Pending-task editing and draft resumption own project selection (and
+ // must not fall through to the replace("NewTask") fallback while their
+ // hydration is in flight).
+ if (props.pendingTaskId || props.draftId) {
return;
}
if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
@@ -518,6 +559,7 @@ export function NewTaskDraftScreen(props: {
props.initialProjectRef,
props.incomingShareId,
props.pendingTaskId,
+ props.draftId,
navigation,
selectedProject,
selectedProjectKey,
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index 4020c1de9417..5df507ea671b 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -43,11 +43,14 @@ import { useEnvironmentQuery } from "../../state/query";
import {
appendComposerDraftAttachments,
clearComposerDraft,
- copyComposerDraftContentIfEmpty,
+ composerDraftsAtom,
+ createNewTaskDraft,
getComposerDraftSnapshot,
isComposerDraftEmpty,
+ isNewTaskDraftKey,
removeComposerDraftAttachment,
replaceComposerDraftAttachments,
+ retargetNewTaskDraft,
scheduleUnusedComposerAttachmentCleanup,
setComposerDraftText,
setStickyComposerModelSelection,
@@ -169,6 +172,12 @@ type NewTaskFlowContextValue = {
readonly filteredBranches: ReadonlyArray;
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 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
? selectedEnvironmentIdOverride
: (projects[0]?.environmentId ?? null);
const [selectedProjectKey, setSelectedProjectKey] = useState(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(null);
const [submitting, setSubmitting] = useState(false);
const [branchQuery, setBranchQuery] = useState("");
const [expandedProvider, setExpandedProvider] = useState(null);
@@ -247,6 +259,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const reset = useCallback(() => {
setSelectedEnvironmentId(null);
setSelectedProjectKey(null);
+ setActiveDraftKey(null);
setSubmitting(false);
setBranchQuery("");
setExpandedProvider(null);
@@ -367,12 +380,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
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
- ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
+ ? activeDraftKey
: 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 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
);
}, [availableBranches, branchQuery]);
- // New-task drafts are keyed per (environment, project), so retargeting the
- // composer would otherwise show the target's empty draft and strand what the
- // user typed under the old key.
+ // 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.
const carryDraftContentTo = useCallback(
(project: EnvironmentProject) => {
- const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`;
- if (
- selectedProjectDraftKey?.startsWith("new-task:") &&
- selectedProjectDraftKey !== nextDraftKey
- ) {
- void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
+ const target = { environmentId: project.environmentId, projectId: project.id };
+ if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) {
+ retargetNewTaskDraft(activeDraftKey, target);
+ } else if (!editingPendingTaskRef.current) {
+ setActiveDraftKey(createNewTaskDraft(target));
}
},
- [selectedProjectDraftKey],
+ [activeDraftKey],
);
const setProject = useCallback(
@@ -650,6 +678,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
[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 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
filteredBranches,
reset,
setProject,
+ openDraft,
selectEnvironment,
setSelectedModelKey,
setWorkspaceMode,
@@ -1148,6 +1202,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedProjectKey,
selectedWorktreePath,
setProject,
+ openDraft,
selectBranch,
selectEnvironment,
setInteractionMode,
diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts
index 6b040b698e3d..2ebacc740891 100644
--- a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts
+++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts
@@ -230,6 +230,24 @@ describe("draft upload scope and offline submission", () => {
);
});
+ 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 = {
diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts
index 071afefa4c7d..538b343abeb0 100644
--- a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts
+++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts
@@ -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 @@ export function composerAttachmentUploadKey(
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 @@ export function composerDraftEnvironmentId(
?.environmentId ?? null
);
}
- const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey;
- const separator = scope.lastIndexOf(":");
- return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null;
+ if (draftKey.startsWith("new-task:")) {
+ if (draft?.project) {
+ return draft.project.environmentId;
+ }
+ 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 = {
diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts
index ad38551d5915..d454133f1a31 100644
--- a/apps/mobile/src/state/composer-attachment-uploads.ts
+++ b/apps/mobile/src/state/composer-attachment-uploads.ts
@@ -79,7 +79,7 @@ export function useComposerAttachmentUploadWorker() {
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 @@ export function useComposerAttachmentUploadWorker() {
.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) =>
diff --git a/apps/mobile/src/state/new-task-draft-key.ts b/apps/mobile/src/state/new-task-draft-key.ts
new file mode 100644
index 000000000000..942380dbaa3d
--- /dev/null
+++ b/apps/mobile/src/state/new-task-draft-key.ts
@@ -0,0 +1,30 @@
+const NEW_TASK_DRAFT_PREFIX = "new-task:";
+
+/** Every new-task draft key: `new-task:`. */
+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::`,
+ * 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) };
+}
diff --git a/apps/mobile/src/state/pending-new-tasks-model.test.ts b/apps/mobile/src/state/pending-new-tasks-model.test.ts
index 6cedca3814a8..0bb61ed2c429 100644
--- a/apps/mobile/src/state/pending-new-tasks-model.test.ts
+++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts
@@ -3,11 +3,10 @@ import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3too
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 queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
};
}
-function draft(text: string, overrides: Partial = {}): ComposerDraft {
- return { text, attachments: [], ...overrides };
+function draft(
+ text: string,
+ createdAt: string,
+ overrides: Partial = {},
+): ComposerDraft {
+ return {
+ 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:${environmentId}:${projectId}`]: draft("fix the offline outbox", {
+ "new-task:draft-old": draft("first idea", "2026-09-05T09:00:00.000Z", {
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[0]).toMatchObject({
- key: `draft-task:new-task:${environmentId}:${projectId}`,
+ expect(tasks[1]).toMatchObject({
+ 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 @@ describe("buildPendingNewTasks", () => {
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 @@ describe("buildPendingNewTasks", () => {
queuedCreation("new", "2026-09-05T10:00:00.000Z"),
],
drafts: {},
- now: NOW,
});
expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]);
diff --git a/apps/mobile/src/state/pending-new-tasks-model.ts b/apps/mobile/src/state/pending-new-tasks-model.ts
index 3dafea540456..b66c5273b443 100644
--- a/apps/mobile/src/state/pending-new-tasks-model.ts
+++ b/apps/mobile/src/state/pending-new-tasks-model.ts
@@ -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 @@ export interface PendingDraftTask {
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::` 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 @@ function draftTitle(draft: ComposerDraft): string {
export function buildPendingNewTasks(input: {
readonly queuedMessages: ReadonlyArray;
readonly drafts: Readonly>;
- /** ISO timestamp drafts sort by; they carry no creation time of their own. */
- readonly now: string;
}): ReadonlyArray {
const tasks: PendingNewTask[] = [];
for (const message of input.queuedMessages) {
@@ -104,26 +82,25 @@ export function buildPendingNewTasks(input: {
});
}
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: input.now,
+ createdAt: draft.project.createdAt,
draftKey,
draft,
});
}
- // Drafts are what the user is writing now, so they lead; queued tasks
- // follow newest-first.
+ // Drafts are what the user is writing now, so they lead; within each kind,
+ // newest first.
tasks.sort((left, right) => {
if (left.kind !== right.kind) {
return left.kind === "draft" ? -1 : 1;
diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts
index 57c0ac91d147..c055b515448a 100644
--- a/apps/mobile/src/state/use-composer-drafts.test.ts
+++ b/apps/mobile/src/state/use-composer-drafts.test.ts
@@ -3,6 +3,7 @@ import {
CommandId,
EnvironmentId,
MessageId,
+ ProjectId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
@@ -157,20 +158,22 @@ import {
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 @@ afterEach(() => {
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 @@ describe("mobile composer drafts", () => {
});
it("hydrates selector state even when the message content is empty", () => {
- expect(
+ const hydrated = Object.entries(
decodePersistedComposerState({
schemaVersion: 1,
drafts: {
@@ -1031,26 +1003,33 @@ describe("mobile composer drafts", () => {
},
},
}).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 @@ describe("mobile composer drafts", () => {
// 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 @@ describe("mobile composer drafts", () => {
},
},
}).drafts,
- ).toEqual({
- "new-task:environment-1:project-1": {
- text: "",
- attachments: [],
- importedShareIds: ["share-1"],
- },
+ );
+ expect(stripped).toHaveLength(1);
+ expect(stripped[0]).toMatchObject({
+ text: "",
+ attachments: [],
+ importedShareIds: ["share-1"],
+ project: { environmentId: "environment-1", projectId: "project-1" },
});
+ expect(stripped[0]?.modelSelection).toBeUndefined();
- expect(
+ const kept = Object.values(
decodePersistedComposerState({
schemaVersion: 1,
drafts: { "new-task:environment-1:project-1": receiptDraft },
}).drafts,
- ).toEqual({ "new-task:environment-1:project-1": receiptDraft });
+ );
+ 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),
+ [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 @@ describe("mobile composer drafts", () => {
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 = {
- 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 = {
- [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";
+ it("drops another environment's upload stamp when a draft moves across machines", () => {
const uploadedElsewhere: DraftComposerAttachment = {
id: "image-1",
type: "image",
@@ -1454,14 +1510,25 @@ describe("mobile composer drafts", () => {
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 @@ describe("mobile composer drafts", () => {
},
uploadedOnTarget,
]);
- expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]);
});
it("merges shared content into a project draft without duplicating retries", () => {
@@ -1564,19 +1630,36 @@ describe("mobile composer drafts", () => {
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:${retainedEnvironmentId}:project-local`]: DRAFT,
+ "new-task:local-draft": localDraft,
},
environmentId,
),
).toEqual({
[`${retainedEnvironmentId}:thread-local`]: DRAFT,
- [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
+ "new-task:local-draft": localDraft,
});
});
diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts
index bf25866b14ce..0c129ad07508 100644
--- a/apps/mobile/src/state/use-composer-drafts.ts
+++ b/apps/mobile/src/state/use-composer-drafts.ts
@@ -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";
@@ -23,6 +26,11 @@ import {
import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";
import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
import { appAtomRegistry } from "./atom-registry";
+import {
+ isNewTaskDraftKey,
+ newTaskDraftKey,
+ parseLegacyNewTaskDraftKey,
+} from "./new-task-draft-key";
import {
decodeQueuedThreadMessage,
encodeQueuedThreadMessage,
@@ -59,6 +67,18 @@ export interface ComposerDraft {
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 {
@@ -76,7 +96,7 @@ export interface ComposerDraftWorkspaceSelection {
export type ComposerDraftSettingsUpdate = Pick<
ComposerDraft,
- "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection"
+ "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project"
>;
const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
@@ -86,6 +106,12 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
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 @@ const ComposerDraftSchema = Schema.Struct({
runtimeMode: Schema.optional(RuntimeModeSchema),
interactionMode: Schema.optional(ProviderInteractionModeSchema),
workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),
+ project: Schema.optional(ComposerDraftProjectSchema),
});
const PersistedComposerDraftsSchema = Schema.Struct({
@@ -176,6 +203,8 @@ export function isComposerDraftEmpty(draft: ComposerDraft): boolean {
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 @@ 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,
+ draftKey: string,
+ draft: ComposerDraft,
+): Record {
+ 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;
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(
+ 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.
+ isNewTaskDraftKey(key) &&
draft.modelSelection &&
draft.text.length === 0 &&
draft.attachments.length === 0 &&
draft.runtimeMode === undefined &&
draft.interactionMode === undefined &&
draft.workspaceSelection === undefined
- ? { ...draft, modelSelection: undefined }
- : draft,
- ] as const,
+ ? { ...draft, modelSelection: undefined }
+ : draft,
+ now,
+ ),
)
// importedShareIds are share-import receipts: a contentless draft
// carrying one is not empty, or the same native share would be
@@ -229,7 +313,13 @@ export function decodePersistedComposerState(value: unknown): {
Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [
id,
{
- drafts: saved.drafts,
+ // Archived drafts come back through restoreCloudComposerDrafts
+ // without another decode, so they get the same key migration.
+ drafts: Object.fromEntries(
+ Object.entries(saved.drafts).map(([key, draft]) =>
+ migrateLegacyNewTaskDraft(key, draft, now),
+ ),
+ ),
queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage),
},
]),
@@ -622,7 +712,7 @@ export async function archiveCloudComposerDrafts(
const remaining = { ...current };
const savedDrafts = { ...cloud.signedOut[owner]?.drafts };
for (const [key, draft] of Object.entries(current)) {
- const environmentId = composerDraftEnvironmentId(key, queued);
+ const environmentId = composerDraftEnvironmentId(key, queued, draft);
if (environmentId !== null && environmentIds.has(environmentId)) {
savedDrafts[key] = draft;
delete remaining[key];
@@ -808,15 +898,7 @@ export function setComposerDraftText(draftKey: string, value: string): void {
...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);
});
}
@@ -881,15 +963,7 @@ export function replaceComposerDraftAttachments(
...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(
@@ -905,15 +979,7 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
...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 @@ export function updateComposerDraftSettings(
...normalizeDraft(current[draftKey]),
...settings,
};
- if (isEmptyDraft(draft)) {
- const next = { ...current };
- delete next[draftKey];
- return next;
- }
- return {
- ...current,
- [draftKey]: draft,
- };
+ return withComposerDraft(current, draftKey, draft);
});
}
@@ -988,10 +1046,14 @@ export function clearComposerDraftContentState(
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,49 +1090,6 @@ export function restoreComposerDraftSnapshotState(
return next;
}
-export function copyComposerDraftContentState(
- current: Record,
- sourceDraftKey: string,
- targetDraftKey: string,
-): Record {
- 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 {
@@ -1078,19 +1097,6 @@ function stripAttachmentUploadReference(
return rest;
}
-export async function copyComposerDraftContentIfEmpty(
- sourceDraftKey: string,
- targetDraftKey: string,
-): Promise {
- ensureComposerDraftsLoaded();
- if (loadPromise !== null) {
- await loadPromise;
- }
- updateComposerDrafts((current) =>
- copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey),
- );
-}
-
function mergeComposerDraftText(existing: string, incoming: string): string {
if (incoming.length === 0) {
return existing;
@@ -1275,15 +1281,7 @@ export function undoComposerDraftMergeState(
interactionMode: undoSetting("interactionMode"),
workspaceSelection: undoSetting("workspaceSelection"),
};
- if (isEmptyDraft(draft)) {
- const next = { ...current };
- delete next[draftKey];
- return next;
- }
- return {
- ...current,
- [draftKey]: draft,
- };
+ return withComposerDraft(current, draftKey, draft);
}
/** Applies undoComposerDraftMergeState and lands it durably. */
@@ -1355,15 +1353,100 @@ export function removeComposerDraftsForEnvironment(
environmentId: EnvironmentId,
): Record {
const environmentPrefix = `${environmentId}:`;
- const newTaskPrefix = `new-task:${environmentId}:`;
return Object.fromEntries(
Object.entries(drafts).filter(
- ([draftKey]) =>
- !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix),
+ ([draftKey, draft]) =>
+ !draftKey.startsWith(environmentPrefix) && draft.project?.environmentId !== environmentId,
),
);
}
+/**
+ * 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>,
+ project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
+): ReadonlyArray {
+ 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 {
ensureComposerDraftsLoaded();
if (loadPromise !== null) {
diff --git a/apps/mobile/src/state/use-pending-new-tasks.ts b/apps/mobile/src/state/use-pending-new-tasks.ts
index d4d4d5c7c9ea..bf5f0191bf35 100644
--- a/apps/mobile/src/state/use-pending-new-tasks.ts
+++ b/apps/mobile/src/state/use-pending-new-tasks.ts
@@ -20,9 +20,6 @@ export function usePendingNewTasks(): ReadonlyArray {
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],
);
diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts
index 9d8f6f02793f..bc295054038b 100644
--- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts
+++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts
@@ -582,7 +582,7 @@ describe("thread outbox delivered creation recovery", () => {
});
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 @@ describe("thread outbox recovery rollback", () => {
"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(
- `new-task:${message.environmentId}:${message.creation!.projectId}`,
- ),
+ composerDrafts.getComposerDraftSnapshot(`new-task:restored-${message.messageId}`),
).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");
diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts
index 487aa4da4c2f..9f1b6422ba53 100644
--- a/apps/mobile/src/state/use-thread-outbox-drain.ts
+++ b/apps/mobile/src/state/use-thread-outbox-drain.ts
@@ -17,7 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
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 @@ import {
type ComposerDraft,
getComposerDraftSnapshot,
mergeComposerDraftContent,
+ newTaskDraftKey,
replaceComposerDraftAttachments,
removeDeliveredCloudQueuedMessage,
undoComposerDraftMerge,
@@ -369,6 +370,7 @@ export async function restoreRejectedQueuedMessage(
let mergedDraft: ComposerDraft;
try {
+ stampRecoveryDraftProject(queuedMessage, draftKey);
await mergeComposerDraftContent(draftKey, {
text: queuedMessage.text,
attachments: queuedMessage.attachments,
@@ -451,12 +453,31 @@ export async function restoreRejectedQueuedMessage(
}
}
+/**
+ * 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,