authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 12:31:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 12:31:03-07:00
log8e129a0dfb35ac2c03a7320245b5bfb976f5d3bf
treebc3c43712284eec3fb532bfd0cc04d18051d11ce
parentc0bf35466c8aa1862572dfa84b51b6fe30fc82d6
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

15 files changed, 664 insertions(+), 345 deletions(-)

apps/mobile/src/features/home/usePendingTaskListActions.ts+1-3
......@@ -15,8 +15,6 @@ export function usePendingTaskListActions(): {
1515
1616 const openPendingTask = useCallback(
1717 (pendingTask: PendingNewTask) => {
18 // A draft is the project's own new-task composer content, so opening
19 // the project's new-task screen lands on it without extra params.
2018 navigation.navigate("NewTaskSheet", {
2119 screen: "NewTaskDraft",
2220 params: {
......@@ -24,7 +22,7 @@ export function usePendingTaskListActions(): {
2422 projectId: String(pendingTask.projectId),
2523 ...(pendingTask.kind === "pending"
2624 ? { pendingTaskId: String(pendingTask.message.messageId) }
27 : {}),
25 : { draftId: pendingTask.draftKey }),
2826 },
2927 });
3028 },
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+2
......@@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = {
99 readonly projectId?: string | string[];
1010 readonly title?: string | string[];
1111 readonly pendingTaskId?: string | string[];
12 readonly draftId?: string | string[];
1213 readonly incomingShareId?: string | string[];
1314};
1415
......@@ -43,6 +44,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
4344 pendingTaskId={
4445 Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
4546 }
47 draftId={Array.isArray(params.draftId) ? params.draftId[0] : params.draftId}
4648 />
4749 </>
4850 );
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+46-4
......@@ -83,6 +83,7 @@ import {
8383 restoreComposerDraftSnapshot,
8484 scheduleUnusedComposerAttachmentCleanup,
8585 type ComposerDraft,
86 waitForComposerDraftsLoaded,
8687} from "../../state/use-composer-drafts";
8788import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
8889import {
......@@ -150,6 +151,8 @@ export function NewTaskDraftScreen(props: {
150151 };
151152 /** Queued outbox message id when editing an existing pending task. */
152153 readonly pendingTaskId?: string;
154 /** Existing new-task draft key to resume (a Draft row in the thread list). */
155 readonly draftId?: string;
153156 /** Durable native share inbox item to merge into this project draft. */
154157 readonly incomingShareId?: string;
155158}) {
......@@ -420,7 +423,44 @@ export function NewTaskDraftScreen(props: {
420423 };
421424 }, []);
422425
423 const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow;
426 const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow;
427 // A Draft row opens its own draft; a fresh New Task never reuses one.
428 // Drafts hydrate from disk and projects arrive with the shell snapshot, so
429 // on a cold launch the draft or its project can be missing for a moment;
430 // wait for hydration and retry while projects load. Attempt each id once
431 // after that so a draft discarded mid-session does not keep bouncing to
432 // the picker.
433 const attemptedDraftIdRef = useRef<string | null>(null);
434 useEffect(() => {
435 if (!props.draftId || props.pendingTaskId) {
436 return;
437 }
438 const draftId = props.draftId;
439 if (attemptedDraftIdRef.current === draftId) {
440 return;
441 }
442 let cancelled = false;
443 void waitForComposerDraftsLoaded().then(() => {
444 if (cancelled || attemptedDraftIdRef.current === draftId) {
445 return;
446 }
447 if (openDraft(draftId)) {
448 attemptedDraftIdRef.current = draftId;
449 return;
450 }
451 if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) {
452 // The draft exists; its project has not arrived yet. Retry on the
453 // next projects change instead of giving up.
454 return;
455 }
456 attemptedDraftIdRef.current = draftId;
457 navigation.dispatch(StackActions.replace("NewTask"));
458 });
459 return () => {
460 cancelled = true;
461 };
462 }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]);
463
424464 const attemptedPendingTaskIdRef = useRef<string | null>(null);
425465 useEffect(() => {
426466 if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) {
......@@ -457,9 +497,10 @@ export function NewTaskDraftScreen(props: {
457497 const lastInitialProjectRefRef = useRef(props.initialProjectRef);
458498
459499 useEffect(() => {
460 // Pending-task editing owns project selection (and must not fall through
461 // to the replace("NewTask") fallback while its hydration is in flight).
462 if (props.pendingTaskId) {
500 // Pending-task editing and draft resumption own project selection (and
501 // must not fall through to the replace("NewTask") fallback while their
502 // hydration is in flight).
503 if (props.pendingTaskId || props.draftId) {
463504 return;
464505 }
465506 if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
......@@ -518,6 +559,7 @@ export function NewTaskDraftScreen(props: {
518559 props.initialProjectRef,
519560 props.incomingShareId,
520561 props.pendingTaskId,
562 props.draftId,
521563 navigation,
522564 selectedProject,
523565 selectedProjectKey,
apps/mobile/src/features/threads/new-task-flow-provider.tsx+68-13
......@@ -43,11 +43,14 @@ import { useEnvironmentQuery } from "../../state/query";
4343import {
4444 appendComposerDraftAttachments,
4545 clearComposerDraft,
46 copyComposerDraftContentIfEmpty,
46 composerDraftsAtom,
47 createNewTaskDraft,
4748 getComposerDraftSnapshot,
4849 isComposerDraftEmpty,
50 isNewTaskDraftKey,
4951 removeComposerDraftAttachment,
5052 replaceComposerDraftAttachments,
53 retargetNewTaskDraft,
5154 scheduleUnusedComposerAttachmentCleanup,
5255 setComposerDraftText,
5356 setStickyComposerModelSelection,
......@@ -169,6 +172,12 @@ type NewTaskFlowContextValue = {
169172 readonly filteredBranches: ReadonlyArray<VcsRef>;
170173 readonly reset: () => void;
171174 readonly setProject: (project: EnvironmentProject) => void;
175 /**
176 * Binds the composer to an existing new-task draft (a row in the thread
177 * list). Returns false when the draft is gone, so the caller can fall back
178 * to a fresh one.
179 */
180 readonly openDraft: (draftKey: string) => boolean;
172181 readonly selectEnvironment: (environmentId: EnvironmentId) => void;
173182 readonly setSelectedModelKey: (
174183 key: string | null,
......@@ -232,6 +241,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
232241 ? selectedEnvironmentIdOverride
233242 : (projects[0]?.environmentId ?? null);
234243 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
244 // The new-task draft the composer is bound to. Null until a project is
245 // chosen; each New Task entry mints its own, so a project can hold several.
246 const [activeDraftKey, setActiveDraftKey] = useState<string | null>(null);
235247 const [submitting, setSubmitting] = useState(false);
236248 const [branchQuery, setBranchQuery] = useState("");
237249 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
......@@ -247,6 +259,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
247259 const reset = useCallback(() => {
248260 setSelectedEnvironmentId(null);
249261 setSelectedProjectKey(null);
262 setActiveDraftKey(null);
250263 setSubmitting(false);
251264 setBranchQuery("");
252265 setExpandedProvider(null);
......@@ -367,12 +380,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
367380 selectedProject?.environmentId ?? null,
368381 );
369382 // While a queued pending task is being edited its draft lives under a key
370 // scoped to the queued message, so per-project new-task drafts stay intact.
383 // scoped to the queued message, so new-task drafts stay intact.
371384 const selectedProjectDraftKey = editingPendingTask
372385 ? pendingTaskDraftKey(editingPendingTask.messageId)
373386 : selectedProject
374 ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
387 ? activeDraftKey
375388 : null;
389 // selectedProject can resolve without setProject ever running (the
390 // environment's first project is the fallback, and the draft screen skips
391 // setProject when the route's project already matches it). The composer
392 // still needs a draft to write into, so bind one the moment a project is
393 // in view and nothing else owns the key.
394 useEffect(() => {
395 if (activeDraftKey !== null || editingPendingTask !== null || selectedProject === null) {
396 return;
397 }
398 setActiveDraftKey(
399 createNewTaskDraft({
400 environmentId: selectedProject.environmentId,
401 projectId: selectedProject.id,
402 }),
403 );
404 }, [activeDraftKey, editingPendingTask, selectedProject]);
376405 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
377406 const prompt = selectedProjectDraft.text;
378407 const attachments = selectedProjectDraft.attachments;
......@@ -625,20 +654,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
625654 );
626655 }, [availableBranches, branchQuery]);
627656
628 // New-task drafts are keyed per (environment, project), so retargeting the
629 // composer would otherwise show the target's empty draft and strand what the
630 // user typed under the old key.
657 // The composer's draft follows the project it will be sent to: switching
658 // mid-compose keeps the same draft and moves it, so typed text follows the
659 // user. A pending-task edit owns its own key and is untouched here.
631660 const carryDraftContentTo = useCallback(
632661 (project: EnvironmentProject) => {
633 const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`;
634 if (
635 selectedProjectDraftKey?.startsWith("new-task:") &&
636 selectedProjectDraftKey !== nextDraftKey
637 ) {
638 void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
662 const target = { environmentId: project.environmentId, projectId: project.id };
663 if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) {
664 retargetNewTaskDraft(activeDraftKey, target);
665 } else if (!editingPendingTaskRef.current) {
666 setActiveDraftKey(createNewTaskDraft(target));
639667 }
640668 },
641 [selectedProjectDraftKey],
669 [activeDraftKey],
642670 );
643671
644672 const setProject = useCallback(
......@@ -650,6 +678,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
650678 [carryDraftContentTo],
651679 );
652680
681 const openDraft = useCallback(
682 (draftKey: string): boolean => {
683 const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
684 const stamp = draft?.project;
685 if (!isNewTaskDraftKey(draftKey) || !stamp) {
686 return false;
687 }
688 // The stamped project must be loaded: selectedProject falls back to
689 // the environment's first project otherwise, and the draft would be
690 // sent somewhere the user never chose.
691 const projectLoaded = projects.some(
692 (project) =>
693 project.environmentId === stamp.environmentId && project.id === stamp.projectId,
694 );
695 if (!projectLoaded) {
696 return false;
697 }
698 setActiveDraftKey(draftKey);
699 setSelectedEnvironmentId(stamp.environmentId);
700 setSelectedProjectKey(scopedProjectKey(stamp.environmentId, stamp.projectId));
701 return true;
702 },
703 [projects],
704 );
705
653706 const selectEnvironment = useCallback(
654707 (environmentId: EnvironmentId) => {
655708 const match = resolveEnvironmentProjectMatch(
......@@ -1085,6 +1138,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
10851138 filteredBranches,
10861139 reset,
10871140 setProject,
1141 openDraft,
10881142 selectEnvironment,
10891143 setSelectedModelKey,
10901144 setWorkspaceMode,
......@@ -1148,6 +1202,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
11481202 selectedProjectKey,
11491203 selectedWorktreePath,
11501204 setProject,
1205 openDraft,
11511206 selectBranch,
11521207 selectEnvironment,
11531208 setInteractionMode,
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts+18
......@@ -230,6 +230,24 @@ describe("draft upload scope and offline submission", () => {
230230 );
231231 });
232232
233 it("reads an id-keyed new-task draft's environment from its project stamp", () => {
234 const stamped = {
235 project: {
236 environmentId,
237 projectId: "project" as never,
238 createdAt: "2026-09-05T00:00:00.000Z",
239 },
240 };
241 expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId);
242 // An id-keyed draft that lost its stamp belongs to nobody: uploads must
243 // not start and sign-out must not sweep it into some other environment.
244 expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull();
245 // The stamp wins over a legacy-looking key when both are present.
246 expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe(
247 environmentId,
248 );
249 });
250
233251 it("allows offline queuing while a connected composer waits for upload or retry", () => {
234252 const key = composerAttachmentUploadKey(environmentId, "file");
235253 const input = {
apps/mobile/src/lib/composerAttachmentUploadQueue.ts+17-3
......@@ -1,6 +1,7 @@
11import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
22import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
33
4import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key";
45import type { DraftComposerAttachment } from "./composerImages";
56
67export interface ComposerAttachmentUploadRequest {
......@@ -20,12 +21,19 @@ export function composerAttachmentUploadKey(
2021 return `${environmentId}:${attachmentId}`;
2122}
2223
24/**
25 * Which environment a composer draft belongs to. Thread drafts carry it in
26 * the key; pending-task editor drafts borrow it from the queued message;
27 * new-task drafts carry it in their project stamp (legacy project-keyed
28 * new-task drafts still parse from the key until they are migrated on load).
29 */
2330export function composerDraftEnvironmentId(
2431 draftKey: string,
2532 queuedMessages: ReadonlyArray<{
2633 readonly messageId: string;
2734 readonly environmentId: EnvironmentId;
2835 }>,
36 draft?: { readonly project?: { readonly environmentId: EnvironmentId } },
2937): EnvironmentId | null {
3038 if (draftKey.startsWith("pending-task:")) {
3139 return (
......@@ -33,9 +41,15 @@ export function composerDraftEnvironmentId(
3341 ?.environmentId ?? null
3442 );
3543 }
36 const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey;
37 const separator = scope.lastIndexOf(":");
38 return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null;
44 if (draftKey.startsWith("new-task:")) {
45 if (draft?.project) {
46 return draft.project.environmentId;
47 }
48 const legacy = parseLegacyNewTaskDraftKey(draftKey);
49 return legacy === null ? null : EnvironmentId.make(legacy.environmentId);
50 }
51 const separator = draftKey.lastIndexOf(":");
52 return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null;
3953}
4054
4155type UploadServerConfig = {
apps/mobile/src/state/composer-attachment-uploads.ts+2-2
......@@ -79,7 +79,7 @@ export function useComposerAttachmentUploadWorker() {
7979 let retained = false;
8080 for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) {
8181 if (
82 composerDraftEnvironmentId(key, queued) === environmentId &&
82 composerDraftEnvironmentId(key, queued, draft) === environmentId &&
8383 draft.attachments.some((candidate) => candidate.id === attachment.id)
8484 ) {
8585 retained = setComposerDraftAttachmentUpload(key, uploaded) || retained;
......@@ -113,7 +113,7 @@ export function useComposerAttachmentUploadWorker() {
113113 .map((environment) => environment.environmentId),
114114 );
115115 const requests = Object.entries(drafts).flatMap(([key, draft]) => {
116 const environmentId = composerDraftEnvironmentId(key, queued);
116 const environmentId = composerDraftEnvironmentId(key, queued, draft);
117117 if (environmentId === null || !connected.has(environmentId)) return [];
118118 return draft.attachments
119119 .filter((attachment) =>
apps/mobile/src/state/new-task-draft-key.ts created+30
......@@ -0,0 +1,30 @@
1const NEW_TASK_DRAFT_PREFIX = "new-task:";
2
3/** Every new-task draft key: `new-task:<draftId>`. */
4export function newTaskDraftKey(draftId: string): string {
5 return `${NEW_TASK_DRAFT_PREFIX}${draftId}`;
6}
7
8export function isNewTaskDraftKey(draftKey: string): boolean {
9 return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX);
10}
11
12/**
13 * Builds before drafts were id-keyed used `new-task:<environmentId>:<projectId>`,
14 * one slot per project. Ids are UUIDs and never contain a colon, so a colon
15 * after the prefix marks the legacy shape. Returns the split scope, or null
16 * when the key is not legacy.
17 */
18export function parseLegacyNewTaskDraftKey(
19 draftKey: string,
20): { readonly environmentId: string; readonly projectId: string } | null {
21 if (!isNewTaskDraftKey(draftKey)) {
22 return null;
23 }
24 const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
25 const separator = scope.lastIndexOf(":");
26 if (separator <= 0 || separator === scope.length - 1) {
27 return null;
28 }
29 return { environmentId: scope.slice(0, separator), projectId: scope.slice(separator + 1) };
30}
apps/mobile/src/state/pending-new-tasks-model.test.ts+30-36
......@@ -3,11 +3,10 @@ import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3too
33
44import type { QueuedThreadMessage } from "./thread-outbox-model";
55import type { ComposerDraft } from "./use-composer-drafts";
6import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model";
6import { buildPendingNewTasks } from "./pending-new-tasks-model";
77
88const environmentId = EnvironmentId.make("env-1");
99const projectId = ProjectId.make("project-1");
10const NOW = "2026-09-05T12:00:00.000Z";
1110
1211function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
1312 return {
......@@ -27,62 +26,57 @@ function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
2726 };
2827}
2928
30function draft(text: string, overrides: Partial<ComposerDraft> = {}): ComposerDraft {
31 return { text, attachments: [], ...overrides };
29function draft(
30 text: string,
31 createdAt: string,
32 overrides: Partial<ComposerDraft> = {},
33): ComposerDraft {
34 return {
35 text,
36 attachments: [],
37 project: { environmentId, projectId, createdAt },
38 ...overrides,
39 };
3240}
3341
34describe("parseNewTaskDraftKey", () => {
35 it("splits the environment and project ids", () => {
36 expect(parseNewTaskDraftKey(`new-task:${environmentId}:${projectId}`)).toEqual({
37 environmentId,
38 projectId,
39 });
40 });
41
42 it("ignores thread drafts and pending-task editor drafts", () => {
43 expect(parseNewTaskDraftKey(`${environmentId}:thread-1`)).toBeNull();
44 expect(parseNewTaskDraftKey("pending-task:message-1")).toBeNull();
45 expect(parseNewTaskDraftKey("new-task:")).toBeNull();
46 expect(parseNewTaskDraftKey("new-task:env-only")).toBeNull();
47 });
48});
49
5042describe("buildPendingNewTasks", () => {
51 it("surfaces new-task drafts with content alongside queued creations", () => {
43 it("surfaces every new-task draft with content alongside queued creations", () => {
5244 const tasks = buildPendingNewTasks({
5345 queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")],
5446 drafts: {
55 [`new-task:${environmentId}:${projectId}`]: draft("fix the offline outbox", {
47 "new-task:draft-old": draft("first idea", "2026-09-05T09:00:00.000Z", {
5648 workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null },
5749 }),
50 "new-task:draft-new": draft("second idea", "2026-09-05T11:00:00.000Z"),
5851 },
59 now: NOW,
6052 });
6153
6254 expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([
63 ["draft", "fix the offline outbox", "main"],
55 ["draft", "second idea", null],
56 ["draft", "first idea", "main"],
6457 ["pending", "queued a", "main"],
6558 ]);
66 expect(tasks[0]).toMatchObject({
67 key: `draft-task:new-task:${environmentId}:${projectId}`,
59 expect(tasks[1]).toMatchObject({
60 key: "draft-task:new-task:draft-old",
6861 environmentId,
6962 projectId,
70 draftKey: `new-task:${environmentId}:${projectId}`,
63 draftKey: "new-task:draft-old",
64 createdAt: "2026-09-05T09:00:00.000Z",
7165 });
7266 });
7367
74 it("hides settings-only drafts and drafts for other surfaces", () => {
68 it("hides settings-only drafts, unstamped drafts, and drafts for other surfaces", () => {
7569 const tasks = buildPendingNewTasks({
7670 queuedMessages: [],
7771 drafts: {
78 [`new-task:${environmentId}:${projectId}`]: draft("", {
72 "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", {
7973 modelSelection: { instanceId: "codex" as never, model: "gpt" },
8074 }),
81 [`new-task:${environmentId}:${projectId}-2`]: draft(" "),
82 [`${environmentId}:thread-1`]: draft("thread composer text"),
83 "pending-task:message-1": draft("editor copy of a queued task"),
75 "new-task:blank": draft(" ", "2026-09-05T09:00:00.000Z"),
76 "new-task:unstamped": { text: "no project", attachments: [] },
77 [`${environmentId}:thread-1`]: { text: "thread composer text", attachments: [] },
78 "pending-task:message-1": { text: "editor copy of a queued task", attachments: [] },
8479 },
85 now: NOW,
8680 });
8781
8882 expect(tasks).toEqual([]);
......@@ -102,9 +96,10 @@ describe("buildPendingNewTasks", () => {
10296 const tasks = buildPendingNewTasks({
10397 queuedMessages: [],
10498 drafts: {
105 [`new-task:${environmentId}:${projectId}`]: draft("", { attachments: [attachment] }),
99 "new-task:with-image": draft("", "2026-09-05T09:00:00.000Z", {
100 attachments: [attachment],
101 }),
106102 },
107 now: NOW,
108103 });
109104
110105 expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]);
......@@ -118,7 +113,6 @@ describe("buildPendingNewTasks", () => {
118113 queuedCreation("new", "2026-09-05T10:00:00.000Z"),
119114 ],
120115 drafts: {},
121 now: NOW,
122116 });
123117
124118 expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]);
apps/mobile/src/state/pending-new-tasks-model.ts+11-34
......@@ -1,15 +1,16 @@
1import { EnvironmentId, ProjectId } from "@t3tools/contracts";
1import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
22
33import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
44import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model";
5import { isNewTaskDraftKey } from "./new-task-draft-key";
56import type { ComposerDraft } from "./use-composer-drafts";
67
78/**
89 * Unsent work that will become a thread, shaped for thread-list presentation.
910 * A `pending` task sits in the outbox and sends itself when its environment
10 * reconnects; a `draft` is the project's new-task composer content, which
11 * only sends when the user submits it. Both share the list slot so the user
12 * can find everything they have written but not yet started in one place.
11 * reconnects; a `draft` is new-task composer content, which only sends when
12 * the user submits it. Both share the list slot so the user can find
13 * everything they have written but not yet started in one place.
1314 */
1415export type PendingNewTask = PendingQueuedTask | PendingDraftTask;
1516
......@@ -36,32 +37,11 @@ export interface PendingDraftTask {
3637 readonly projectCwd: undefined;
3738 readonly branch: string | null;
3839 readonly title: string;
39 /** Drafts have no creation timestamp; they sort as current work. */
4040 readonly createdAt: string;
4141 readonly draftKey: string;
4242 readonly draft: ComposerDraft;
4343}
4444
45const NEW_TASK_DRAFT_PREFIX = "new-task:";
46
47/** Parses a `new-task:<environmentId>:<projectId>` composer draft key. */
48export function parseNewTaskDraftKey(
49 draftKey: string,
50): { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } | null {
51 if (!draftKey.startsWith(NEW_TASK_DRAFT_PREFIX)) {
52 return null;
53 }
54 const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
55 const separator = scope.lastIndexOf(":");
56 if (separator <= 0 || separator === scope.length - 1) {
57 return null;
58 }
59 return {
60 environmentId: EnvironmentId.make(scope.slice(0, separator)),
61 projectId: ProjectId.make(scope.slice(separator + 1)),
62 };
63}
64
6545/**
6646 * Settings-only drafts (a model pick with no text) are not work the user
6747 * would look for in the list; only text or attachments make a draft visible.
......@@ -81,8 +61,6 @@ function draftTitle(draft: ComposerDraft): string {
8161export function buildPendingNewTasks(input: {
8262 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
8363 readonly drafts: Readonly<Record<string, ComposerDraft>>;
84 /** ISO timestamp drafts sort by; they carry no creation time of their own. */
85 readonly now: string;
8664}): ReadonlyArray<PendingNewTask> {
8765 const tasks: PendingNewTask[] = [];
8866 for (const message of input.queuedMessages) {
......@@ -104,26 +82,25 @@ export function buildPendingNewTasks(input: {
10482 });
10583 }
10684 for (const [draftKey, draft] of Object.entries(input.drafts)) {
107 const ref = parseNewTaskDraftKey(draftKey);
108 if (ref === null || !composerDraftHasUserContent(draft)) {
85 if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) {
10986 continue;
11087 }
11188 tasks.push({
11289 kind: "draft",
11390 key: `draft-task:${draftKey}`,
114 environmentId: ref.environmentId,
115 projectId: ref.projectId,
91 environmentId: draft.project.environmentId,
92 projectId: draft.project.projectId,
11693 projectTitle: undefined,
11794 projectCwd: undefined,
11895 branch: draft.workspaceSelection?.branch ?? null,
11996 title: draftTitle(draft),
120 createdAt: input.now,
97 createdAt: draft.project.createdAt,
12198 draftKey,
12299 draft,
123100 });
124101 }
125 // Drafts are what the user is writing now, so they lead; queued tasks
126 // follow newest-first.
102 // Drafts are what the user is writing now, so they lead; within each kind,
103 // newest first.
127104 tasks.sort((left, right) => {
128105 if (left.kind !== right.kind) {
129106 return left.kind === "draft" ? -1 : 1;
apps/mobile/src/state/use-composer-drafts.test.ts+203-120
......@@ -3,6 +3,7 @@ import {
33 CommandId,
44 EnvironmentId,
55 MessageId,
6 ProjectId,
67 ProviderInstanceId,
78 ThreadId,
89} from "@t3tools/contracts";
......@@ -157,20 +158,22 @@ import {
157158 ComposerDraftPersistenceError,
158159 composerDraftsAtom,
159160 composerCloudDraftsAtom,
160 copyComposerDraftContentIfEmpty,
161 copyComposerDraftContentState,
161 createNewTaskDraft,
162162 decodePersistedComposerState,
163163 ensureComposerDraftsLoaded,
164164 type ComposerDraft,
165 findNewTaskDraftKeys,
165166 flushComposerDrafts,
166167 getComposerDraftSnapshot,
167168 mergeComposerDraftContentState,
169 migrateLegacyNewTaskDraft,
168170 releaseUnusedComposerAttachmentFiles,
169171 removeComposerDraftsForEnvironment,
170172 resetComposerDraftsLoadState,
171173 retainComposerAttachmentFileForPreview,
172174 restoreComposerDraftSnapshotState,
173175 restoreCloudComposerDrafts,
176 retargetNewTaskDraft,
174177 setComposerDraftText,
175178 setComposerDraftAttachmentUpload,
176179 waitForComposerDraftsLoaded,
......@@ -210,37 +213,6 @@ afterEach(() => {
210213describe("mobile composer drafts", () => {
211214 // Hydration is one-shot per module instance and the attachment sweep now
212215 // triggers it too, so this test must observe it before any sweep test runs.
213 it("waits for persisted drafts before copying content between projects", async () => {
214 const sourceKey = "new-task:environment-1:project-1";
215 const targetKey = "new-task:environment-1:project-2";
216 const unrelatedKey = "environment-1:thread-1";
217 const source = { text: "Current task", attachments: [] } satisfies ComposerDraft;
218 const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft;
219 const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft;
220
221 composerDraftFileMocks.setDocument({
222 schemaVersion: 1,
223 drafts: {
224 [targetKey]: target,
225 [unrelatedKey]: unrelated,
226 },
227 });
228 composerDraftFileMocks.blockRead();
229 appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source });
230
231 const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey);
232 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source });
233
234 composerDraftFileMocks.releaseRead();
235 await copy;
236
237 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
238 [sourceKey]: source,
239 [targetKey]: target,
240 [unrelatedKey]: unrelated,
241 });
242 });
243
244216 it("hydrates generic file attachments from their saved local paths", () => {
245217 const file = {
246218 id: "file-1",
......@@ -1009,7 +981,7 @@ describe("mobile composer drafts", () => {
1009981 });
1010982
1011983 it("hydrates selector state even when the message content is empty", () => {
1012 expect(
984 const hydrated = Object.entries(
1013985 decodePersistedComposerState({
1014986 schemaVersion: 1,
1015987 drafts: {
......@@ -1031,26 +1003,33 @@ describe("mobile composer drafts", () => {
10311003 },
10321004 },
10331005 }).drafts,
1034 ).toEqual({
1035 "new-task:environment-1:project-1": {
1036 text: "",
1037 attachments: [],
1038 modelSelection: {
1039 instanceId: "codex",
1040 model: "gpt-5.4",
1041 options: [{ id: "reasoningEffort", value: "xhigh" }],
1042 },
1043 runtimeMode: "approval-required",
1044 interactionMode: "plan",
1045 workspaceSelection: {
1046 mode: "worktree",
1047 branch: "main",
1048 worktreePath: null,
1049 },
1006 );
1007 expect(hydrated).toHaveLength(1);
1008 const [key, draft] = hydrated[0]!;
1009 // Legacy project keys are rewritten to id keys on load.
1010 expect(key).toMatch(/^new-task:[0-9a-z-]+$/);
1011 expect(draft).toEqual({
1012 text: "",
1013 attachments: [],
1014 modelSelection: {
1015 instanceId: "codex",
1016 model: "gpt-5.4",
1017 options: [{ id: "reasoningEffort", value: "xhigh" }],
1018 },
1019 runtimeMode: "approval-required",
1020 interactionMode: "plan",
1021 workspaceSelection: {
1022 mode: "worktree",
1023 branch: "main",
1024 worktreePath: null,
1025 },
1026 project: {
1027 environmentId: "environment-1",
1028 projectId: "project-1",
1029 createdAt: expect.any(String),
10501030 },
10511031 });
10521032 });
1053
10541033 it("keeps legacy content-only drafts and rejects invalid selector state", () => {
10551034 expect(
10561035 decodePersistedComposerState({
......@@ -1085,7 +1064,7 @@ describe("mobile composer drafts", () => {
10851064 // The stale-model strip must not touch receipt-bearing drafts, and the
10861065 // empty filter must keep them — or the same share would re-import after
10871066 // restart.
1088 expect(
1067 const stripped = Object.values(
10891068 decodePersistedComposerState({
10901069 schemaVersion: 1,
10911070 drafts: {
......@@ -1098,20 +1077,146 @@ describe("mobile composer drafts", () => {
10981077 },
10991078 },
11001079 }).drafts,
1101 ).toEqual({
1102 "new-task:environment-1:project-1": {
1103 text: "",
1104 attachments: [],
1105 importedShareIds: ["share-1"],
1106 },
1080 );
1081 expect(stripped).toHaveLength(1);
1082 expect(stripped[0]).toMatchObject({
1083 text: "",
1084 attachments: [],
1085 importedShareIds: ["share-1"],
1086 project: { environmentId: "environment-1", projectId: "project-1" },
11071087 });
1088 expect(stripped[0]?.modelSelection).toBeUndefined();
11081089
1109 expect(
1090 const kept = Object.values(
11101091 decodePersistedComposerState({
11111092 schemaVersion: 1,
11121093 drafts: { "new-task:environment-1:project-1": receiptDraft },
11131094 }).drafts,
1114 ).toEqual({ "new-task:environment-1:project-1": receiptDraft });
1095 );
1096 expect(kept).toHaveLength(1);
1097 expect(kept[0]).toMatchObject(receiptDraft);
1098 });
1099
1100 it("migrates archived signed-out new-task drafts the same way as live ones", () => {
1101 const decoded = decodePersistedComposerState({
1102 schemaVersion: 1,
1103 drafts: {},
1104 cloudAccountId: "account-1",
1105 signedOutDrafts: {
1106 "account-1": {
1107 drafts: { "new-task:environment-1:project-1": { text: "archived", attachments: [] } },
1108 queuedMessages: [],
1109 },
1110 },
1111 });
1112 const archived = Object.entries(decoded.cloudDrafts.signedOut["account-1"]?.drafts ?? {});
1113 expect(archived).toHaveLength(1);
1114 expect(archived[0]?.[0]).toMatch(/^new-task:[0-9a-z]+-[0-9a-z]+$/);
1115 expect(archived[0]?.[1]).toMatchObject({
1116 text: "archived",
1117 project: { environmentId: "environment-1", projectId: "project-1" },
1118 });
1119 });
1120
1121 it("migrates project-keyed new-task drafts to id keys with the project stamped in", () => {
1122 const now = "2026-09-05T12:00:00.000Z";
1123 const [key, draft] = migrateLegacyNewTaskDraft(
1124 "new-task:environment-1:project-1",
1125 { text: "keep me", attachments: [] },
1126 now,
1127 );
1128 // The new key has no colon after the prefix, so it can never be
1129 // mistaken for the legacy shape on the next load.
1130 expect(key).toMatch(/^new-task:[0-9a-z-]+$/);
1131 expect(draft).toEqual({
1132 text: "keep me",
1133 attachments: [],
1134 project: {
1135 environmentId: EnvironmentId.make("environment-1"),
1136 projectId: ProjectId.make("project-1"),
1137 createdAt: now,
1138 },
1139 });
1140
1141 // Already-migrated, thread, and pending-task keys pass through untouched.
1142 const stamped: ComposerDraft = {
1143 text: "x",
1144 attachments: [],
1145 project: {
1146 environmentId: EnvironmentId.make("environment-1"),
1147 projectId: ProjectId.make("project-1"),
1148 createdAt: now,
1149 },
1150 };
1151 expect(migrateLegacyNewTaskDraft("new-task:some-id", stamped, now)).toEqual([
1152 "new-task:some-id",
1153 stamped,
1154 ]);
1155 expect(migrateLegacyNewTaskDraft("environment-1:thread-1", DRAFT, now)).toEqual([
1156 "environment-1:thread-1",
1157 DRAFT,
1158 ]);
1159 expect(migrateLegacyNewTaskDraft("pending-task:message-1", DRAFT, now)).toEqual([
1160 "pending-task:message-1",
1161 DRAFT,
1162 ]);
1163 });
1164
1165 it("keeps a freshly minted new-task draft bound until content arrives, then lists it per project", () => {
1166 const project = {
1167 environmentId: EnvironmentId.make("environment-1"),
1168 projectId: ProjectId.make("project-1"),
1169 };
1170 const first = createNewTaskDraft(project);
1171 const second = createNewTaskDraft(project);
1172 expect(first).not.toBe(second);
1173 // Empty stamped drafts stay in memory so the composer has a key to write
1174 // to, but the persisted document leaves them out.
1175 expect(appAtomRegistry.get(composerDraftsAtom)[first]?.project).toMatchObject(project);
1176
1177 setComposerDraftText(first, "first idea");
1178 setComposerDraftText(second, "second idea");
1179 expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), project)).toEqual(
1180 expect.arrayContaining([first, second]),
1181 );
1182
1183 // Clearing content on the way out drops the stamp with it.
1184 clearComposerDraftContent(first, { clearModelSelection: true, clearWorkspaceSelection: true });
1185 expect(appAtomRegistry.get(composerDraftsAtom)[first]).toBeUndefined();
1186 expect(getComposerDraftSnapshot(second).text).toBe("second idea");
1187 });
1188
1189 it("retargets a new-task draft to another project without losing its text", () => {
1190 const from = {
1191 environmentId: EnvironmentId.make("environment-1"),
1192 projectId: ProjectId.make("project-1"),
1193 };
1194 const to = {
1195 environmentId: EnvironmentId.make("environment-2"),
1196 projectId: ProjectId.make("project-2"),
1197 };
1198 const key = createNewTaskDraft(from);
1199 setComposerDraftText(key, "moving house");
1200 appAtomRegistry.set(composerDraftsAtom, {
1201 ...appAtomRegistry.get(composerDraftsAtom),
1202 [key]: {
1203 ...getComposerDraftSnapshot(key),
1204 runtimeMode: "approval-required",
1205 workspaceSelection: { mode: "worktree", branch: "feature/a", worktreePath: null },
1206 },
1207 });
1208 const createdAt = getComposerDraftSnapshot(key).project?.createdAt;
1209
1210 retargetNewTaskDraft(key, to);
1211
1212 const moved = getComposerDraftSnapshot(key);
1213 expect(moved.text).toBe("moving house");
1214 expect(moved.runtimeMode).toBe("approval-required");
1215 // Branch and worktree belong to the old repo.
1216 expect(moved.workspaceSelection).toBeUndefined();
1217 expect(moved.project).toEqual({ ...to, createdAt });
1218 expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]);
1219 expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]);
11151220 });
11161221
11171222 it("hydrates the global sticky model selection", () => {
......@@ -1387,56 +1492,7 @@ describe("mobile composer drafts", () => {
13871492 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
13881493 });
13891494
1390 it("carries unfinished content to a newly selected project without overwriting its settings", () => {
1391 const sourceKey = "new-task:environment-1:project-1";
1392 const targetKey = "new-task:environment-1:project-2";
1393 const source: ComposerDraft = {
1394 text: "Keep this task",
1395 attachments: [],
1396 importedShareIds: ["share-1"],
1397 workspaceSelection: {
1398 mode: "worktree",
1399 branch: "feature/source",
1400 worktreePath: null,
1401 },
1402 };
1403 const target: ComposerDraft = {
1404 text: "",
1405 attachments: [],
1406 runtimeMode: "approval-required",
1407 };
1408
1409 expect(
1410 copyComposerDraftContentState(
1411 { [sourceKey]: source, [targetKey]: target },
1412 sourceKey,
1413 targetKey,
1414 ),
1415 ).toEqual({
1416 [sourceKey]: source,
1417 [targetKey]: {
1418 ...target,
1419 text: source.text,
1420 attachments: source.attachments,
1421 importedShareIds: source.importedShareIds,
1422 },
1423 });
1424 });
1425
1426 it("does not overwrite unfinished content already stored for the selected project", () => {
1427 const sourceKey = "new-task:environment-1:project-1";
1428 const targetKey = "new-task:environment-1:project-2";
1429 const drafts: Record<string, ComposerDraft> = {
1430 [sourceKey]: { text: "Source task", attachments: [] },
1431 [targetKey]: { text: "Target task", attachments: [] },
1432 };
1433
1434 expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts);
1435 });
1436
1437 it("drops another environment's upload stamp when carrying attachments across machines", () => {
1438 const sourceKey = "new-task:environment-1:project-1";
1439 const targetKey = "new-task:environment-2:project-2";
1495 it("drops another environment's upload stamp when a draft moves across machines", () => {
14401496 const uploadedElsewhere: DraftComposerAttachment = {
14411497 id: "image-1",
14421498 type: "image",
......@@ -1454,14 +1510,25 @@ describe("mobile composer drafts", () => {
14541510 uploadedAttachmentId: "upload-2",
14551511 uploadEnvironmentId: EnvironmentId.make("environment-2"),
14561512 };
1513 const key = createNewTaskDraft({
1514 environmentId: EnvironmentId.make("environment-1"),
1515 projectId: ProjectId.make("project-1"),
1516 });
1517 appAtomRegistry.set(composerDraftsAtom, {
1518 ...appAtomRegistry.get(composerDraftsAtom),
1519 [key]: {
1520 ...getComposerDraftSnapshot(key),
1521 text: "Ship it",
1522 attachments: [uploadedElsewhere, uploadedOnTarget],
1523 },
1524 });
14571525
1458 const next = copyComposerDraftContentState(
1459 { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } },
1460 sourceKey,
1461 targetKey,
1462 );
1526 retargetNewTaskDraft(key, {
1527 environmentId: EnvironmentId.make("environment-2"),
1528 projectId: ProjectId.make("project-2"),
1529 });
14631530
1464 expect(next[targetKey]?.attachments).toEqual([
1531 expect(getComposerDraftSnapshot(key).attachments).toEqual([
14651532 {
14661533 id: "image-1",
14671534 type: "image",
......@@ -1473,7 +1540,6 @@ describe("mobile composer drafts", () => {
14731540 },
14741541 uploadedOnTarget,
14751542 ]);
1476 expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]);
14771543 });
14781544
14791545 it("merges shared content into a project draft without duplicating retries", () => {
......@@ -1564,19 +1630,36 @@ describe("mobile composer drafts", () => {
15641630 const environmentId = EnvironmentId.make("environment-cloud");
15651631 const retainedEnvironmentId = EnvironmentId.make("environment-local");
15661632
1633 const cloudDraft: ComposerDraft = {
1634 ...DRAFT,
1635 project: {
1636 environmentId,
1637 projectId: ProjectId.make("project-cloud"),
1638 createdAt: "2026-09-05T00:00:00.000Z",
1639 },
1640 };
1641 const localDraft: ComposerDraft = {
1642 ...DRAFT,
1643 project: {
1644 environmentId: retainedEnvironmentId,
1645 projectId: ProjectId.make("project-local"),
1646 createdAt: "2026-09-05T00:00:00.000Z",
1647 },
1648 };
1649
15671650 expect(
15681651 removeComposerDraftsForEnvironment(
15691652 {
15701653 [`${environmentId}:thread-cloud`]: DRAFT,
1571 [`new-task:${environmentId}:project-cloud`]: DRAFT,
1654 "new-task:cloud-draft": cloudDraft,
15721655 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
1573 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
1656 "new-task:local-draft": localDraft,
15741657 },
15751658 environmentId,
15761659 ),
15771660 ).toEqual({
15781661 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
1579 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
1662 "new-task:local-draft": localDraft,
15801663 });
15811664 });
15821665
apps/mobile/src/state/use-composer-drafts.ts+204-121
......@@ -1,11 +1,14 @@
11import { useAtomValue } from "@effect/atom-react";
22import {
3 EnvironmentId as EnvironmentIdSchema,
34 ModelSelection as ModelSelectionSchema,
45 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
6 ProjectId as ProjectIdSchema,
57 ProviderInteractionMode as ProviderInteractionModeSchema,
68 RuntimeMode as RuntimeModeSchema,
79 type EnvironmentId,
810 type ModelSelection,
11 type ProjectId,
912 type ProviderInteractionMode,
1013 type RuntimeMode,
1114} from "@t3tools/contracts";
......@@ -23,6 +26,11 @@ import {
2326import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";
2427import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
2528import { appAtomRegistry } from "./atom-registry";
29import {
30 isNewTaskDraftKey,
31 newTaskDraftKey,
32 parseLegacyNewTaskDraftKey,
33} from "./new-task-draft-key";
2634import {
2735 decodeQueuedThreadMessage,
2836 encodeQueuedThreadMessage,
......@@ -59,6 +67,18 @@ export interface ComposerDraft {
5967 readonly runtimeMode?: RuntimeMode;
6068 readonly interactionMode?: ProviderInteractionMode;
6169 readonly workspaceSelection?: ComposerDraftWorkspaceSelection;
70 /**
71 * Set on new-task drafts only. The project is stored here rather than in
72 * the key so a project can hold any number of drafts and a draft can be
73 * retargeted to another project without changing identity.
74 */
75 readonly project?: ComposerDraftProject;
76}
77
78export interface ComposerDraftProject {
79 readonly environmentId: EnvironmentId;
80 readonly projectId: ProjectId;
81 readonly createdAt: string;
6282}
6383
6484export interface ComposerDraftContent {
......@@ -76,7 +96,7 @@ export interface ComposerDraftWorkspaceSelection {
7696
7797export type ComposerDraftSettingsUpdate = Pick<
7898 ComposerDraft,
79 "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection"
99 "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project"
80100>;
81101
82102const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
......@@ -86,6 +106,12 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
86106 startFromOrigin: Schema.optional(Schema.Boolean),
87107});
88108
109const ComposerDraftProjectSchema = Schema.Struct({
110 environmentId: EnvironmentIdSchema,
111 projectId: ProjectIdSchema,
112 createdAt: Schema.String,
113});
114
89115const ComposerDraftSchema = Schema.Struct({
90116 text: Schema.String,
91117 attachments: Schema.Array(DraftComposerAttachmentSchema),
......@@ -94,6 +120,7 @@ const ComposerDraftSchema = Schema.Struct({
94120 runtimeMode: Schema.optional(RuntimeModeSchema),
95121 interactionMode: Schema.optional(ProviderInteractionModeSchema),
96122 workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),
123 project: Schema.optional(ComposerDraftProjectSchema),
97124});
98125
99126const PersistedComposerDraftsSchema = Schema.Struct({
......@@ -176,6 +203,8 @@ export function isComposerDraftEmpty(draft: ComposerDraft): boolean {
176203 return isEmptyDraft(draft);
177204}
178205
206// The project stamp is identity, not content: a new-task draft with nothing
207// else in it is still empty and gets dropped like any other.
179208function isEmptyDraft(draft: ComposerDraft): boolean {
180209 return (
181210 draft.text.length === 0 &&
......@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
187216 );
188217}
189218
219/**
220 * Writes a draft back, dropping it once empty. A new-task draft keeps its
221 * entry while the composer is bound to it (the project stamp is what the
222 * composer binds to); the persist sweep still leaves empty ones off disk.
223 */
224function withComposerDraft(
225 current: Record<string, ComposerDraft>,
226 draftKey: string,
227 draft: ComposerDraft,
228): Record<string, ComposerDraft> {
229 if (isEmptyDraft(draft) && draft.project === undefined) {
230 const next = { ...current };
231 delete next[draftKey];
232 return next;
233 }
234 return { ...current, [draftKey]: draft };
235}
236
237export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key";
238
239// Draft ids only need to be unique within this device's draft file. Deriving
240// them from time plus randomness keeps this module free of native imports,
241// which the persistence tests rely on.
242function newDraftId(): string {
243 return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
244}
245
246/**
247 * Project-keyed new-task drafts from earlier builds are rewritten on load into
248 * id-keyed drafts with the project stamped in, so existing drafts survive the
249 * switch to many-per-project.
250 */
251export function migrateLegacyNewTaskDraft(
252 key: string,
253 draft: ComposerDraft,
254 now: string,
255): readonly [key: string, draft: ComposerDraft] {
256 const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null;
257 if (legacy === null) {
258 return [key, draft];
259 }
260 return [
261 newTaskDraftKey(newDraftId()),
262 {
263 ...draft,
264 project: {
265 environmentId: EnvironmentIdSchema.make(legacy.environmentId),
266 projectId: ProjectIdSchema.make(legacy.projectId),
267 createdAt: now,
268 },
269 },
270 ];
271}
272
190273export function decodePersistedComposerState(value: unknown): {
191274 readonly drafts: Record<string, ComposerDraft>;
192275 readonly stickyModelSelection: ModelSelection | null;
193276 readonly cloudDrafts: ComposerCloudDraftState;
194277} {
195278 const parsed = decodePersistedComposerDraftsDocument(value);
279 const now = new Date().toISOString();
196280 return {
197281 drafts: Object.fromEntries(
198282 Object.entries(parsed.drafts)
199 .map(
200 ([key, draft]) =>
201 [
202 key,
203 // Stale new-task drafts left on disk by builds before the
204 // model-precedence fix carry a bare modelSelection with no
205 // other selector settings. Strip it so the next compose pass
206 // re-resolves project → sticky → provider defaults. Drafts
207 // with runtime/interaction/workspace settings or actual text /
208 // attachments were deliberately configured and are left alone.
209 key.startsWith("new-task:") &&
283 .map(([key, draft]) =>
284 migrateLegacyNewTaskDraft(
285 key,
286 // Stale new-task drafts left on disk by builds before the
287 // model-precedence fix carry a bare modelSelection with no
288 // other selector settings. Strip it so the next compose pass
289 // re-resolves project → sticky → provider defaults. Drafts
290 // with runtime/interaction/workspace settings or actual text /
291 // attachments were deliberately configured and are left alone.
292 isNewTaskDraftKey(key) &&
210293 draft.modelSelection &&
211294 draft.text.length === 0 &&
212295 draft.attachments.length === 0 &&
213296 draft.runtimeMode === undefined &&
214297 draft.interactionMode === undefined &&
215298 draft.workspaceSelection === undefined
216 ? { ...draft, modelSelection: undefined }
217 : draft,
218 ] as const,
299 ? { ...draft, modelSelection: undefined }
300 : draft,
301 now,
302 ),
219303 )
220304 // importedShareIds are share-import receipts: a contentless draft
221305 // carrying one is not empty, or the same native share would be
......@@ -229,7 +313,13 @@ export function decodePersistedComposerState(value: unknown): {
229313 Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [
230314 id,
231315 {
232 drafts: saved.drafts,
316 // Archived drafts come back through restoreCloudComposerDrafts
317 // without another decode, so they get the same key migration.
318 drafts: Object.fromEntries(
319 Object.entries(saved.drafts).map(([key, draft]) =>
320 migrateLegacyNewTaskDraft(key, draft, now),
321 ),
322 ),
233323 queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage),
234324 },
235325 ]),
......@@ -622,7 +712,7 @@ export async function archiveCloudComposerDrafts(
622712 const remaining = { ...current };
623713 const savedDrafts = { ...cloud.signedOut[owner]?.drafts };
624714 for (const [key, draft] of Object.entries(current)) {
625 const environmentId = composerDraftEnvironmentId(key, queued);
715 const environmentId = composerDraftEnvironmentId(key, queued, draft);
626716 if (environmentId !== null && environmentIds.has(environmentId)) {
627717 savedDrafts[key] = draft;
628718 delete remaining[key];
......@@ -808,15 +898,7 @@ export function setComposerDraftText(draftKey: string, value: string): void {
808898 ...normalizeDraft(current[draftKey]),
809899 text: value,
810900 };
811 if (isEmptyDraft(draft)) {
812 const next = { ...current };
813 delete next[draftKey];
814 return next;
815 }
816 return {
817 ...current,
818 [draftKey]: draft,
819 };
901 return withComposerDraft(current, draftKey, draft);
820902 });
821903}
822904
......@@ -881,15 +963,7 @@ export function replaceComposerDraftAttachments(
881963 ...normalizeDraft(current[draftKey]),
882964 attachments,
883965 };
884 if (isEmptyDraft(draft)) {
885 const next = { ...current };
886 delete next[draftKey];
887 return next;
888 }
889 return {
890 ...current,
891 [draftKey]: draft,
892 };
966 return withComposerDraft(current, draftKey, draft);
893967 });
894968 const retainedIds = new Set(attachments.map((attachment) => attachment.id));
895969 scheduleUnusedComposerAttachmentCleanup(
......@@ -905,15 +979,7 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
905979 ...existing,
906980 attachments: existing.attachments.filter((image) => image.id !== imageId),
907981 };
908 if (isEmptyDraft(draft)) {
909 const next = { ...current };
910 delete next[draftKey];
911 return next;
912 }
913 return {
914 ...current,
915 [draftKey]: draft,
916 };
982 return withComposerDraft(current, draftKey, draft);
917983 });
918984 scheduleUnusedComposerAttachmentCleanup(
919985 previousAttachments.filter((attachment) => attachment.id === imageId),
......@@ -964,15 +1030,7 @@ export function updateComposerDraftSettings(
9641030 ...normalizeDraft(current[draftKey]),
9651031 ...settings,
9661032 };
967 if (isEmptyDraft(draft)) {
968 const next = { ...current };
969 delete next[draftKey];
970 return next;
971 }
972 return {
973 ...current,
974 [draftKey]: draft,
975 };
1033 return withComposerDraft(current, draftKey, draft);
9761034 });
9771035}
9781036
......@@ -988,10 +1046,14 @@ export function clearComposerDraftContentState(
9881046 if (!existing) {
9891047 return current;
9901048 }
1049 // Clearing content is the "this draft is done" moment (sent, queued, or
1050 // discarded), so the project stamp goes too and an otherwise-empty new-task
1051 // draft leaves the store rather than lingering as a blank row.
9911052 const {
9921053 importedShareIds: _importedShareIds,
9931054 modelSelection,
9941055 workspaceSelection,
1056 project: _project,
9951057 ...retained
9961058 } = existing;
9971059 const draft = {
......@@ -1028,49 +1090,6 @@ export function restoreComposerDraftSnapshotState(
10281090 return next;
10291091}
10301092
1031export function copyComposerDraftContentState(
1032 current: Record<string, ComposerDraft>,
1033 sourceDraftKey: string,
1034 targetDraftKey: string,
1035): Record<string, ComposerDraft> {
1036 if (sourceDraftKey === targetDraftKey) {
1037 return current;
1038 }
1039 const source = normalizeDraft(current[sourceDraftKey]);
1040 const target = normalizeDraft(current[targetDraftKey]);
1041 const sourceHasContent =
1042 source.text.length > 0 ||
1043 source.attachments.length > 0 ||
1044 (source.importedShareIds?.length ?? 0) > 0;
1045 const targetHasContent =
1046 target.text.length > 0 ||
1047 target.attachments.length > 0 ||
1048 (target.importedShareIds?.length ?? 0) > 0;
1049 if (!sourceHasContent || targetHasContent) {
1050 return current;
1051 }
1052 // Pending uploads live on one server. Crossing environments keeps the local
1053 // bytes (the upload worker re-sends them to the new key's environment) but
1054 // drops the old stamp, so it cannot pin the source environment's pending
1055 // upload alive from the copy.
1056 const targetEnvironmentId = composerDraftEnvironmentId(targetDraftKey, []);
1057 const attachments = source.attachments.map((attachment) =>
1058 attachment.uploadEnvironmentId !== undefined &&
1059 attachment.uploadEnvironmentId !== targetEnvironmentId
1060 ? stripAttachmentUploadReference(attachment)
1061 : attachment,
1062 );
1063 return {
1064 ...current,
1065 [targetDraftKey]: {
1066 ...target,
1067 text: source.text,
1068 attachments,
1069 ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}),
1070 },
1071 };
1072}
1073
10741093function stripAttachmentUploadReference(
10751094 attachment: DraftComposerAttachment,
10761095): DraftComposerAttachment {
......@@ -1078,19 +1097,6 @@ function stripAttachmentUploadReference(
10781097 return rest;
10791098}
10801099
1081export async function copyComposerDraftContentIfEmpty(
1082 sourceDraftKey: string,
1083 targetDraftKey: string,
1084): Promise<void> {
1085 ensureComposerDraftsLoaded();
1086 if (loadPromise !== null) {
1087 await loadPromise;
1088 }
1089 updateComposerDrafts((current) =>
1090 copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey),
1091 );
1092}
1093
10941100function mergeComposerDraftText(existing: string, incoming: string): string {
10951101 if (incoming.length === 0) {
10961102 return existing;
......@@ -1275,15 +1281,7 @@ export function undoComposerDraftMergeState(
12751281 interactionMode: undoSetting("interactionMode"),
12761282 workspaceSelection: undoSetting("workspaceSelection"),
12771283 };
1278 if (isEmptyDraft(draft)) {
1279 const next = { ...current };
1280 delete next[draftKey];
1281 return next;
1282 }
1283 return {
1284 ...current,
1285 [draftKey]: draft,
1286 };
1284 return withComposerDraft(current, draftKey, draft);
12871285}
12881286
12891287/** Applies undoComposerDraftMergeState and lands it durably. */
......@@ -1355,15 +1353,100 @@ export function removeComposerDraftsForEnvironment(
13551353 environmentId: EnvironmentId,
13561354): Record<string, ComposerDraft> {
13571355 const environmentPrefix = `${environmentId}:`;
1358 const newTaskPrefix = `new-task:${environmentId}:`;
13591356 return Object.fromEntries(
13601357 Object.entries(drafts).filter(
1361 ([draftKey]) =>
1362 !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix),
1358 ([draftKey, draft]) =>
1359 !draftKey.startsWith(environmentPrefix) && draft.project?.environmentId !== environmentId,
13631360 ),
13641361 );
13651362}
13661363
1364/**
1365 * Mints a new-task draft for a project. The entry is published immediately so
1366 * the composer can bind to its key before the user types; it stays out of the
1367 * list until it has content, and the empty-draft sweep drops it on persist if
1368 * nothing is ever written.
1369 */
1370export function createNewTaskDraft(project: {
1371 readonly environmentId: EnvironmentId;
1372 readonly projectId: ProjectId;
1373}): string {
1374 const draftKey = newTaskDraftKey(newDraftId());
1375 const stamp: ComposerDraftProject = {
1376 environmentId: project.environmentId,
1377 projectId: project.projectId,
1378 createdAt: new Date().toISOString(),
1379 };
1380 updateComposerDrafts((current) => ({
1381 ...current,
1382 [draftKey]: { ...EMPTY_DRAFT, project: stamp },
1383 }));
1384 return draftKey;
1385}
1386
1387/**
1388 * Points an existing new-task draft at a different project, keeping its
1389 * content and identity. Workspace selection is project-specific (branch,
1390 * worktree), so it is cleared; model and mode choices carry over.
1391 */
1392export function retargetNewTaskDraft(
1393 draftKey: string,
1394 project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
1395): void {
1396 updateComposerDrafts((current) => {
1397 const existing = current[draftKey];
1398 const stamp = existing?.project;
1399 if (
1400 stamp !== undefined &&
1401 stamp.environmentId === project.environmentId &&
1402 stamp.projectId === project.projectId
1403 ) {
1404 return current;
1405 }
1406 const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing);
1407 // Pending uploads live on one server. Crossing environments keeps the
1408 // local bytes (the upload worker re-sends them to the new environment)
1409 // but drops the old stamp, so it cannot pin the source environment's
1410 // pending upload alive from the moved draft.
1411 const attachments = retained.attachments.map((attachment) =>
1412 attachment.uploadEnvironmentId !== undefined &&
1413 attachment.uploadEnvironmentId !== project.environmentId
1414 ? stripAttachmentUploadReference(attachment)
1415 : attachment,
1416 );
1417 return {
1418 ...current,
1419 [draftKey]: {
1420 ...retained,
1421 attachments,
1422 project: {
1423 environmentId: project.environmentId,
1424 projectId: project.projectId,
1425 createdAt: stamp?.createdAt ?? new Date().toISOString(),
1426 },
1427 },
1428 };
1429 });
1430}
1431
1432/** New-task drafts for a project, newest first. */
1433export function findNewTaskDraftKeys(
1434 drafts: Readonly<Record<string, ComposerDraft>>,
1435 project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
1436): ReadonlyArray<string> {
1437 return Object.entries(drafts)
1438 .filter(
1439 ([key, draft]) =>
1440 isNewTaskDraftKey(key) &&
1441 draft.project?.environmentId === project.environmentId &&
1442 draft.project.projectId === project.projectId,
1443 )
1444 .sort(([, left], [, right]) =>
1445 (right.project?.createdAt ?? "").localeCompare(left.project?.createdAt ?? ""),
1446 )
1447 .map(([key]) => key);
1448}
1449
13671450export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise<void> {
13681451 ensureComposerDraftsLoaded();
13691452 if (loadPromise !== null) {
apps/mobile/src/state/use-pending-new-tasks.ts-3
......@@ -20,9 +20,6 @@ export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> {
2020 buildPendingNewTasks({
2121 queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey),
2222 drafts,
23 // Stamped when the inputs change, not per render, so a draft keeps one
24 // sort position while the user is not typing in it.
25 now: new Date().toISOString(),
2623 }),
2724 [queuedMessagesByThreadKey, drafts],
2825 );
apps/mobile/src/state/use-thread-outbox-drain.test.ts+9-4
......@@ -582,7 +582,7 @@ describe("thread outbox delivered creation recovery", () => {
582582});
583583
584584describe("thread outbox recovery rollback", () => {
585 it("restores a rejected new task into its durable project draft", async () => {
585 it("restores a rejected new task as its own draft for the project", async () => {
586586 const message: QueuedThreadMessage = {
587587 ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }),
588588 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
......@@ -599,14 +599,19 @@ describe("thread outbox recovery rollback", () => {
599599 "restored",
600600 );
601601
602 // The draft is keyed by the message so a retry lands on the same one, and
603 // stamped with the project so it shows up as a Draft row for that project.
602604 expect(
603 composerDrafts.getComposerDraftSnapshot(
604 `new-task:${message.environmentId}:${message.creation!.projectId}`,
605 ),
605 composerDrafts.getComposerDraftSnapshot(`new-task:restored-${message.messageId}`),
606606 ).toMatchObject({
607607 text: message.text,
608608 attachments: message.attachments,
609609 modelSelection: message.modelSelection,
610 project: {
611 environmentId: message.environmentId,
612 projectId: message.creation!.projectId,
613 createdAt: message.createdAt,
614 },
610615 });
611616 expect(remainingMessages()).toEqual([]);
612617 expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server");
apps/mobile/src/state/use-thread-outbox-drain.ts+23-2
......@@ -17,7 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
1717import { useCallback, useEffect, useRef, useState } from "react";
1818import { Alert } from "react-native";
1919
20import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
20import { scopedThreadKey } from "../lib/scopedEntities";
2121import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
2222import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
2323import { randomHex } from "../lib/uuid";
......@@ -53,6 +53,7 @@ import {
5353 type ComposerDraft,
5454 getComposerDraftSnapshot,
5555 mergeComposerDraftContent,
56 newTaskDraftKey,
5657 replaceComposerDraftAttachments,
5758 removeDeliveredCloudQueuedMessage,
5859 undoComposerDraftMerge,
......@@ -369,6 +370,7 @@ export async function restoreRejectedQueuedMessage(
369370
370371 let mergedDraft: ComposerDraft;
371372 try {
373 stampRecoveryDraftProject(queuedMessage, draftKey);
372374 await mergeComposerDraftContent(draftKey, {
373375 text: queuedMessage.text,
374376 attachments: queuedMessage.attachments,
......@@ -451,12 +453,31 @@ export async function restoreRejectedQueuedMessage(
451453 }
452454}
453455
456/**
457 * A rejected creation becomes its own new-task draft rather than merging into
458 * whatever the user is typing for that project. The key derives from the
459 * message id so a retry after a mid-recovery failure lands on the same draft
460 * instead of minting another.
461 */
454462function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string {
455463 return queuedMessage.creation
456 ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}`
464 ? newTaskDraftKey(`restored-${queuedMessage.messageId}`)
457465 : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);
458466}
459467
468function stampRecoveryDraftProject(queuedMessage: QueuedThreadMessage, draftKey: string): void {
469 if (!queuedMessage.creation) {
470 return;
471 }
472 updateComposerDraftSettings(draftKey, {
473 project: {
474 environmentId: queuedMessage.environmentId,
475 projectId: queuedMessage.creation.projectId,
476 createdAt: queuedMessage.createdAt,
477 },
478 });
479}
480
460481async function preserveUploadedAttachmentsForEditor(
461482 originalMessage: QueuedThreadMessage,
462483 uploadedMessage: QueuedThreadMessage,