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,