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(): {...@@ -15,8 +15,6 @@ export function usePendingTaskListActions(): {
1515
16 const openPendingTask = useCallback(16 const openPendingTask = useCallback(
17 (pendingTask: PendingNewTask) => {17 (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.
20 navigation.navigate("NewTaskSheet", {18 navigation.navigate("NewTaskSheet", {
21 screen: "NewTaskDraft",19 screen: "NewTaskDraft",
22 params: {20 params: {
...@@ -24,7 +22,7 @@ export function usePendingTaskListActions(): {...@@ -24,7 +22,7 @@ export function usePendingTaskListActions(): {
24 projectId: String(pendingTask.projectId),22 projectId: String(pendingTask.projectId),
25 ...(pendingTask.kind === "pending"23 ...(pendingTask.kind === "pending"
26 ? { pendingTaskId: String(pendingTask.message.messageId) }24 ? { pendingTaskId: String(pendingTask.message.messageId) }
27 : {}),25 : { draftId: pendingTask.draftKey }),
28 },26 },
29 });27 });
30 },28 },
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+2
...@@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = {...@@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = {
9 readonly projectId?: string | string[];9 readonly projectId?: string | string[];
10 readonly title?: string | string[];10 readonly title?: string | string[];
11 readonly pendingTaskId?: string | string[];11 readonly pendingTaskId?: string | string[];
12 readonly draftId?: string | string[];
12 readonly incomingShareId?: string | string[];13 readonly incomingShareId?: string | string[];
13};14};
1415
...@@ -43,6 +44,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf...@@ -43,6 +44,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
43 pendingTaskId={44 pendingTaskId={
44 Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId45 Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
45 }46 }
47 draftId={Array.isArray(params.draftId) ? params.draftId[0] : params.draftId}
46 />48 />
47 </>49 </>
48 );50 );
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+46-4
...@@ -83,6 +83,7 @@ import {...@@ -83,6 +83,7 @@ import {
83 restoreComposerDraftSnapshot,83 restoreComposerDraftSnapshot,
84 scheduleUnusedComposerAttachmentCleanup,84 scheduleUnusedComposerAttachmentCleanup,
85 type ComposerDraft,85 type ComposerDraft,
86 waitForComposerDraftsLoaded,
86} from "../../state/use-composer-drafts";87} from "../../state/use-composer-drafts";
87import { useEnvironmentServerConfig, useProjects } from "../../state/entities";88import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
88import {89import {
...@@ -150,6 +151,8 @@ export function NewTaskDraftScreen(props: {...@@ -150,6 +151,8 @@ export function NewTaskDraftScreen(props: {
150 };151 };
151 /** Queued outbox message id when editing an existing pending task. */152 /** Queued outbox message id when editing an existing pending task. */
152 readonly pendingTaskId?: string;153 readonly pendingTaskId?: string;
154 /** Existing new-task draft key to resume (a Draft row in the thread list). */
155 readonly draftId?: string;
153 /** Durable native share inbox item to merge into this project draft. */156 /** Durable native share inbox item to merge into this project draft. */
154 readonly incomingShareId?: string;157 readonly incomingShareId?: string;
155}) {158}) {
...@@ -420,7 +423,44 @@ export function NewTaskDraftScreen(props: {...@@ -420,7 +423,44 @@ export function NewTaskDraftScreen(props: {
420 };423 };
421 }, []);424 }, []);
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
424 const attemptedPendingTaskIdRef = useRef<string | null>(null);464 const attemptedPendingTaskIdRef = useRef<string | null>(null);
425 useEffect(() => {465 useEffect(() => {
426 if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) {466 if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) {
...@@ -457,9 +497,10 @@ export function NewTaskDraftScreen(props: {...@@ -457,9 +497,10 @@ export function NewTaskDraftScreen(props: {
457 const lastInitialProjectRefRef = useRef(props.initialProjectRef);497 const lastInitialProjectRefRef = useRef(props.initialProjectRef);
458498
459 useEffect(() => {499 useEffect(() => {
460 // Pending-task editing owns project selection (and must not fall through500 // Pending-task editing and draft resumption own project selection (and
461 // to the replace("NewTask") fallback while its hydration is in flight).501 // must not fall through to the replace("NewTask") fallback while their
462 if (props.pendingTaskId) {502 // hydration is in flight).
503 if (props.pendingTaskId || props.draftId) {
463 return;504 return;
464 }505 }
465 if (lastInitialProjectRefRef.current !== props.initialProjectRef) {506 if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
...@@ -518,6 +559,7 @@ export function NewTaskDraftScreen(props: {...@@ -518,6 +559,7 @@ export function NewTaskDraftScreen(props: {
518 props.initialProjectRef,559 props.initialProjectRef,
519 props.incomingShareId,560 props.incomingShareId,
520 props.pendingTaskId,561 props.pendingTaskId,
562 props.draftId,
521 navigation,563 navigation,
522 selectedProject,564 selectedProject,
523 selectedProjectKey,565 selectedProjectKey,
apps/mobile/src/features/threads/new-task-flow-provider.tsx+68-13
...@@ -43,11 +43,14 @@ import { useEnvironmentQuery } from "../../state/query";...@@ -43,11 +43,14 @@ import { useEnvironmentQuery } from "../../state/query";
43import {43import {
44 appendComposerDraftAttachments,44 appendComposerDraftAttachments,
45 clearComposerDraft,45 clearComposerDraft,
46 copyComposerDraftContentIfEmpty,46 composerDraftsAtom,
47 createNewTaskDraft,
47 getComposerDraftSnapshot,48 getComposerDraftSnapshot,
48 isComposerDraftEmpty,49 isComposerDraftEmpty,
50 isNewTaskDraftKey,
49 removeComposerDraftAttachment,51 removeComposerDraftAttachment,
50 replaceComposerDraftAttachments,52 replaceComposerDraftAttachments,
53 retargetNewTaskDraft,
51 scheduleUnusedComposerAttachmentCleanup,54 scheduleUnusedComposerAttachmentCleanup,
52 setComposerDraftText,55 setComposerDraftText,
53 setStickyComposerModelSelection,56 setStickyComposerModelSelection,
...@@ -169,6 +172,12 @@ type NewTaskFlowContextValue = {...@@ -169,6 +172,12 @@ type NewTaskFlowContextValue = {
169 readonly filteredBranches: ReadonlyArray<VcsRef>;172 readonly filteredBranches: ReadonlyArray<VcsRef>;
170 readonly reset: () => void;173 readonly reset: () => void;
171 readonly setProject: (project: EnvironmentProject) => void;174 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;
172 readonly selectEnvironment: (environmentId: EnvironmentId) => void;181 readonly selectEnvironment: (environmentId: EnvironmentId) => void;
173 readonly setSelectedModelKey: (182 readonly setSelectedModelKey: (
174 key: string | null,183 key: string | null,
...@@ -232,6 +241,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -232,6 +241,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
232 ? selectedEnvironmentIdOverride241 ? selectedEnvironmentIdOverride
233 : (projects[0]?.environmentId ?? null);242 : (projects[0]?.environmentId ?? null);
234 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);243 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);
235 const [submitting, setSubmitting] = useState(false);247 const [submitting, setSubmitting] = useState(false);
236 const [branchQuery, setBranchQuery] = useState("");248 const [branchQuery, setBranchQuery] = useState("");
237 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);249 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
...@@ -247,6 +259,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -247,6 +259,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
247 const reset = useCallback(() => {259 const reset = useCallback(() => {
248 setSelectedEnvironmentId(null);260 setSelectedEnvironmentId(null);
249 setSelectedProjectKey(null);261 setSelectedProjectKey(null);
262 setActiveDraftKey(null);
250 setSubmitting(false);263 setSubmitting(false);
251 setBranchQuery("");264 setBranchQuery("");
252 setExpandedProvider(null);265 setExpandedProvider(null);
...@@ -367,12 +380,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -367,12 +380,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
367 selectedProject?.environmentId ?? null,380 selectedProject?.environmentId ?? null,
368 );381 );
369 // While a queued pending task is being edited its draft lives under a key382 // 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.
371 const selectedProjectDraftKey = editingPendingTask384 const selectedProjectDraftKey = editingPendingTask
372 ? pendingTaskDraftKey(editingPendingTask.messageId)385 ? pendingTaskDraftKey(editingPendingTask.messageId)
373 : selectedProject386 : selectedProject
374 ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`387 ? activeDraftKey
375 : null;388 : 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]);
376 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);405 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
377 const prompt = selectedProjectDraft.text;406 const prompt = selectedProjectDraft.text;
378 const attachments = selectedProjectDraft.attachments;407 const attachments = selectedProjectDraft.attachments;
...@@ -625,20 +654,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -625,20 +654,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
625 );654 );
626 }, [availableBranches, branchQuery]);655 }, [availableBranches, branchQuery]);
627656
628 // New-task drafts are keyed per (environment, project), so retargeting the657 // The composer's draft follows the project it will be sent to: switching
629 // composer would otherwise show the target's empty draft and strand what the658 // mid-compose keeps the same draft and moves it, so typed text follows the
630 // user typed under the old key.659 // user. A pending-task edit owns its own key and is untouched here.
631 const carryDraftContentTo = useCallback(660 const carryDraftContentTo = useCallback(
632 (project: EnvironmentProject) => {661 (project: EnvironmentProject) => {
633 const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`;662 const target = { environmentId: project.environmentId, projectId: project.id };
634 if (663 if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) {
635 selectedProjectDraftKey?.startsWith("new-task:") &&664 retargetNewTaskDraft(activeDraftKey, target);
636 selectedProjectDraftKey !== nextDraftKey665 } else if (!editingPendingTaskRef.current) {
637 ) {666 setActiveDraftKey(createNewTaskDraft(target));
638 void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
639 }667 }
640 },668 },
641 [selectedProjectDraftKey],669 [activeDraftKey],
642 );670 );
643671
644 const setProject = useCallback(672 const setProject = useCallback(
...@@ -650,6 +678,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -650,6 +678,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
650 [carryDraftContentTo],678 [carryDraftContentTo],
651 );679 );
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
653 const selectEnvironment = useCallback(706 const selectEnvironment = useCallback(
654 (environmentId: EnvironmentId) => {707 (environmentId: EnvironmentId) => {
655 const match = resolveEnvironmentProjectMatch(708 const match = resolveEnvironmentProjectMatch(
...@@ -1085,6 +1138,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1085,6 +1138,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1085 filteredBranches,1138 filteredBranches,
1086 reset,1139 reset,
1087 setProject,1140 setProject,
1141 openDraft,
1088 selectEnvironment,1142 selectEnvironment,
1089 setSelectedModelKey,1143 setSelectedModelKey,
1090 setWorkspaceMode,1144 setWorkspaceMode,
...@@ -1148,6 +1202,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1148,6 +1202,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1148 selectedProjectKey,1202 selectedProjectKey,
1149 selectedWorktreePath,1203 selectedWorktreePath,
1150 setProject,1204 setProject,
1205 openDraft,
1151 selectBranch,1206 selectBranch,
1152 selectEnvironment,1207 selectEnvironment,
1153 setInteractionMode,1208 setInteractionMode,
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts+18
...@@ -230,6 +230,24 @@ describe("draft upload scope and offline submission", () => {...@@ -230,6 +230,24 @@ describe("draft upload scope and offline submission", () => {
230 );230 );
231 });231 });
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
233 it("allows offline queuing while a connected composer waits for upload or retry", () => {251 it("allows offline queuing while a connected composer waits for upload or retry", () => {
234 const key = composerAttachmentUploadKey(environmentId, "file");252 const key = composerAttachmentUploadKey(environmentId, "file");
235 const input = {253 const input = {
apps/mobile/src/lib/composerAttachmentUploadQueue.ts+17-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";1import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
2import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";2import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
33
4import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key";
4import type { DraftComposerAttachment } from "./composerImages";5import type { DraftComposerAttachment } from "./composerImages";
56
6export interface ComposerAttachmentUploadRequest {7export interface ComposerAttachmentUploadRequest {
...@@ -20,12 +21,19 @@ export function composerAttachmentUploadKey(...@@ -20,12 +21,19 @@ export function composerAttachmentUploadKey(
20 return `${environmentId}:${attachmentId}`;21 return `${environmentId}:${attachmentId}`;
21}22}
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 */
23export function composerDraftEnvironmentId(30export function composerDraftEnvironmentId(
24 draftKey: string,31 draftKey: string,
25 queuedMessages: ReadonlyArray<{32 queuedMessages: ReadonlyArray<{
26 readonly messageId: string;33 readonly messageId: string;
27 readonly environmentId: EnvironmentId;34 readonly environmentId: EnvironmentId;
28 }>,35 }>,
36 draft?: { readonly project?: { readonly environmentId: EnvironmentId } },
29): EnvironmentId | null {37): EnvironmentId | null {
30 if (draftKey.startsWith("pending-task:")) {38 if (draftKey.startsWith("pending-task:")) {
31 return (39 return (
...@@ -33,9 +41,15 @@ export function composerDraftEnvironmentId(...@@ -33,9 +41,15 @@ export function composerDraftEnvironmentId(
33 ?.environmentId ?? null41 ?.environmentId ?? null
34 );42 );
35 }43 }
36 const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey;44 if (draftKey.startsWith("new-task:")) {
37 const separator = scope.lastIndexOf(":");45 if (draft?.project) {
38 return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null;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;
39}53}
4054
41type UploadServerConfig = {55type UploadServerConfig = {
apps/mobile/src/state/composer-attachment-uploads.ts+2-2
...@@ -79,7 +79,7 @@ export function useComposerAttachmentUploadWorker() {...@@ -79,7 +79,7 @@ export function useComposerAttachmentUploadWorker() {
79 let retained = false;79 let retained = false;
80 for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) {80 for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) {
81 if (81 if (
82 composerDraftEnvironmentId(key, queued) === environmentId &&82 composerDraftEnvironmentId(key, queued, draft) === environmentId &&
83 draft.attachments.some((candidate) => candidate.id === attachment.id)83 draft.attachments.some((candidate) => candidate.id === attachment.id)
84 ) {84 ) {
85 retained = setComposerDraftAttachmentUpload(key, uploaded) || retained;85 retained = setComposerDraftAttachmentUpload(key, uploaded) || retained;
...@@ -113,7 +113,7 @@ export function useComposerAttachmentUploadWorker() {...@@ -113,7 +113,7 @@ export function useComposerAttachmentUploadWorker() {
113 .map((environment) => environment.environmentId),113 .map((environment) => environment.environmentId),
114 );114 );
115 const requests = Object.entries(drafts).flatMap(([key, draft]) => {115 const requests = Object.entries(drafts).flatMap(([key, draft]) => {
116 const environmentId = composerDraftEnvironmentId(key, queued);116 const environmentId = composerDraftEnvironmentId(key, queued, draft);
117 if (environmentId === null || !connected.has(environmentId)) return [];117 if (environmentId === null || !connected.has(environmentId)) return [];
118 return draft.attachments118 return draft.attachments
119 .filter((attachment) =>119 .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...@@ -3,11 +3,10 @@ import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3too
33
4import type { QueuedThreadMessage } from "./thread-outbox-model";4import type { QueuedThreadMessage } from "./thread-outbox-model";
5import type { ComposerDraft } from "./use-composer-drafts";5import type { ComposerDraft } from "./use-composer-drafts";
6import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model";6import { buildPendingNewTasks } from "./pending-new-tasks-model";
77
8const environmentId = EnvironmentId.make("env-1");8const environmentId = EnvironmentId.make("env-1");
9const projectId = ProjectId.make("project-1");9const projectId = ProjectId.make("project-1");
10const NOW = "2026-09-05T12:00:00.000Z";
1110
12function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {11function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
13 return {12 return {
...@@ -27,62 +26,57 @@ function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {...@@ -27,62 +26,57 @@ function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
27 };26 };
28}27}
2928
30function draft(text: string, overrides: Partial<ComposerDraft> = {}): ComposerDraft {29function draft(
31 return { text, attachments: [], ...overrides };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 };
32}40}
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
50describe("buildPendingNewTasks", () => {42describe("buildPendingNewTasks", () => {
51 it("surfaces new-task drafts with content alongside queued creations", () => {43 it("surfaces every new-task draft with content alongside queued creations", () => {
52 const tasks = buildPendingNewTasks({44 const tasks = buildPendingNewTasks({
53 queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")],45 queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")],
54 drafts: {46 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", {
56 workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null },48 workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null },
57 }),49 }),
50 "new-task:draft-new": draft("second idea", "2026-09-05T11:00:00.000Z"),
58 },51 },
59 now: NOW,
60 });52 });
6153
62 expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([54 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"],
64 ["pending", "queued a", "main"],57 ["pending", "queued a", "main"],
65 ]);58 ]);
66 expect(tasks[0]).toMatchObject({59 expect(tasks[1]).toMatchObject({
67 key: `draft-task:new-task:${environmentId}:${projectId}`,60 key: "draft-task:new-task:draft-old",
68 environmentId,61 environmentId,
69 projectId,62 projectId,
70 draftKey: `new-task:${environmentId}:${projectId}`,63 draftKey: "new-task:draft-old",
64 createdAt: "2026-09-05T09:00:00.000Z",
71 });65 });
72 });66 });
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", () => {
75 const tasks = buildPendingNewTasks({69 const tasks = buildPendingNewTasks({
76 queuedMessages: [],70 queuedMessages: [],
77 drafts: {71 drafts: {
78 [`new-task:${environmentId}:${projectId}`]: draft("", {72 "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", {
79 modelSelection: { instanceId: "codex" as never, model: "gpt" },73 modelSelection: { instanceId: "codex" as never, model: "gpt" },
80 }),74 }),
81 [`new-task:${environmentId}:${projectId}-2`]: draft(" "),75 "new-task:blank": draft(" ", "2026-09-05T09:00:00.000Z"),
82 [`${environmentId}:thread-1`]: draft("thread composer text"),76 "new-task:unstamped": { text: "no project", attachments: [] },
83 "pending-task:message-1": draft("editor copy of a queued task"),77 [`${environmentId}:thread-1`]: { text: "thread composer text", attachments: [] },
78 "pending-task:message-1": { text: "editor copy of a queued task", attachments: [] },
84 },79 },
85 now: NOW,
86 });80 });
8781
88 expect(tasks).toEqual([]);82 expect(tasks).toEqual([]);
...@@ -102,9 +96,10 @@ describe("buildPendingNewTasks", () => {...@@ -102,9 +96,10 @@ describe("buildPendingNewTasks", () => {
102 const tasks = buildPendingNewTasks({96 const tasks = buildPendingNewTasks({
103 queuedMessages: [],97 queuedMessages: [],
104 drafts: {98 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 }),
106 },102 },
107 now: NOW,
108 });103 });
109104
110 expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]);105 expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]);
...@@ -118,7 +113,6 @@ describe("buildPendingNewTasks", () => {...@@ -118,7 +113,6 @@ describe("buildPendingNewTasks", () => {
118 queuedCreation("new", "2026-09-05T10:00:00.000Z"),113 queuedCreation("new", "2026-09-05T10:00:00.000Z"),
119 ],114 ],
120 drafts: {},115 drafts: {},
121 now: NOW,
122 });116 });
123117
124 expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]);118 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 @@...@@ -1,15 +1,16 @@
1import { EnvironmentId, ProjectId } from "@t3tools/contracts";1import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
22
3import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";3import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
4import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model";4import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model";
5import { isNewTaskDraftKey } from "./new-task-draft-key";
5import type { ComposerDraft } from "./use-composer-drafts";6import type { ComposerDraft } from "./use-composer-drafts";
67
7/**8/**
8 * Unsent work that will become a thread, shaped for thread-list presentation.9 * Unsent work that will become a thread, shaped for thread-list presentation.
9 * A `pending` task sits in the outbox and sends itself when its environment10 * 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, which11 * reconnects; a `draft` is new-task composer content, which only sends when
11 * only sends when the user submits it. Both share the list slot so the user12 * the user submits it. Both share the list slot so the user can find
12 * can find everything they have written but not yet started in one place.13 * everything they have written but not yet started in one place.
13 */14 */
14export type PendingNewTask = PendingQueuedTask | PendingDraftTask;15export type PendingNewTask = PendingQueuedTask | PendingDraftTask;
1516
...@@ -36,32 +37,11 @@ export interface PendingDraftTask {...@@ -36,32 +37,11 @@ export interface PendingDraftTask {
36 readonly projectCwd: undefined;37 readonly projectCwd: undefined;
37 readonly branch: string | null;38 readonly branch: string | null;
38 readonly title: string;39 readonly title: string;
39 /** Drafts have no creation timestamp; they sort as current work. */
40 readonly createdAt: string;40 readonly createdAt: string;
41 readonly draftKey: string;41 readonly draftKey: string;
42 readonly draft: ComposerDraft;42 readonly draft: ComposerDraft;
43}43}
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
65/**45/**
66 * Settings-only drafts (a model pick with no text) are not work the user46 * Settings-only drafts (a model pick with no text) are not work the user
67 * would look for in the list; only text or attachments make a draft visible.47 * would look for in the list; only text or attachments make a draft visible.
...@@ -81,8 +61,6 @@ function draftTitle(draft: ComposerDraft): string {...@@ -81,8 +61,6 @@ function draftTitle(draft: ComposerDraft): string {
81export function buildPendingNewTasks(input: {61export function buildPendingNewTasks(input: {
82 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;62 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
83 readonly drafts: Readonly<Record<string, ComposerDraft>>;63 readonly drafts: Readonly<Record<string, ComposerDraft>>;
84 /** ISO timestamp drafts sort by; they carry no creation time of their own. */
85 readonly now: string;
86}): ReadonlyArray<PendingNewTask> {64}): ReadonlyArray<PendingNewTask> {
87 const tasks: PendingNewTask[] = [];65 const tasks: PendingNewTask[] = [];
88 for (const message of input.queuedMessages) {66 for (const message of input.queuedMessages) {
...@@ -104,26 +82,25 @@ export function buildPendingNewTasks(input: {...@@ -104,26 +82,25 @@ export function buildPendingNewTasks(input: {
104 });82 });
105 }83 }
106 for (const [draftKey, draft] of Object.entries(input.drafts)) {84 for (const [draftKey, draft] of Object.entries(input.drafts)) {
107 const ref = parseNewTaskDraftKey(draftKey);85 if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) {
108 if (ref === null || !composerDraftHasUserContent(draft)) {
109 continue;86 continue;
110 }87 }
111 tasks.push({88 tasks.push({
112 kind: "draft",89 kind: "draft",
113 key: `draft-task:${draftKey}`,90 key: `draft-task:${draftKey}`,
114 environmentId: ref.environmentId,91 environmentId: draft.project.environmentId,
115 projectId: ref.projectId,92 projectId: draft.project.projectId,
116 projectTitle: undefined,93 projectTitle: undefined,
117 projectCwd: undefined,94 projectCwd: undefined,
118 branch: draft.workspaceSelection?.branch ?? null,95 branch: draft.workspaceSelection?.branch ?? null,
119 title: draftTitle(draft),96 title: draftTitle(draft),
120 createdAt: input.now,97 createdAt: draft.project.createdAt,
121 draftKey,98 draftKey,
122 draft,99 draft,
123 });100 });
124 }101 }
125 // Drafts are what the user is writing now, so they lead; queued tasks102 // Drafts are what the user is writing now, so they lead; within each kind,
126 // follow newest-first.103 // newest first.
127 tasks.sort((left, right) => {104 tasks.sort((left, right) => {
128 if (left.kind !== right.kind) {105 if (left.kind !== right.kind) {
129 return left.kind === "draft" ? -1 : 1;106 return left.kind === "draft" ? -1 : 1;
apps/mobile/src/state/use-composer-drafts.test.ts+203-120
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 CommandId,3 CommandId,
4 EnvironmentId,4 EnvironmentId,
5 MessageId,5 MessageId,
6 ProjectId,
6 ProviderInstanceId,7 ProviderInstanceId,
7 ThreadId,8 ThreadId,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
...@@ -157,20 +158,22 @@ import {...@@ -157,20 +158,22 @@ import {
157 ComposerDraftPersistenceError,158 ComposerDraftPersistenceError,
158 composerDraftsAtom,159 composerDraftsAtom,
159 composerCloudDraftsAtom,160 composerCloudDraftsAtom,
160 copyComposerDraftContentIfEmpty,161 createNewTaskDraft,
161 copyComposerDraftContentState,
162 decodePersistedComposerState,162 decodePersistedComposerState,
163 ensureComposerDraftsLoaded,163 ensureComposerDraftsLoaded,
164 type ComposerDraft,164 type ComposerDraft,
165 findNewTaskDraftKeys,
165 flushComposerDrafts,166 flushComposerDrafts,
166 getComposerDraftSnapshot,167 getComposerDraftSnapshot,
167 mergeComposerDraftContentState,168 mergeComposerDraftContentState,
169 migrateLegacyNewTaskDraft,
168 releaseUnusedComposerAttachmentFiles,170 releaseUnusedComposerAttachmentFiles,
169 removeComposerDraftsForEnvironment,171 removeComposerDraftsForEnvironment,
170 resetComposerDraftsLoadState,172 resetComposerDraftsLoadState,
171 retainComposerAttachmentFileForPreview,173 retainComposerAttachmentFileForPreview,
172 restoreComposerDraftSnapshotState,174 restoreComposerDraftSnapshotState,
173 restoreCloudComposerDrafts,175 restoreCloudComposerDrafts,
176 retargetNewTaskDraft,
174 setComposerDraftText,177 setComposerDraftText,
175 setComposerDraftAttachmentUpload,178 setComposerDraftAttachmentUpload,
176 waitForComposerDraftsLoaded,179 waitForComposerDraftsLoaded,
...@@ -210,37 +213,6 @@ afterEach(() => {...@@ -210,37 +213,6 @@ afterEach(() => {
210describe("mobile composer drafts", () => {213describe("mobile composer drafts", () => {
211 // Hydration is one-shot per module instance and the attachment sweep now214 // Hydration is one-shot per module instance and the attachment sweep now
212 // triggers it too, so this test must observe it before any sweep test runs.215 // 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
244 it("hydrates generic file attachments from their saved local paths", () => {216 it("hydrates generic file attachments from their saved local paths", () => {
245 const file = {217 const file = {
246 id: "file-1",218 id: "file-1",
...@@ -1009,7 +981,7 @@ describe("mobile composer drafts", () => {...@@ -1009,7 +981,7 @@ describe("mobile composer drafts", () => {
1009 });981 });
1010982
1011 it("hydrates selector state even when the message content is empty", () => {983 it("hydrates selector state even when the message content is empty", () => {
1012 expect(984 const hydrated = Object.entries(
1013 decodePersistedComposerState({985 decodePersistedComposerState({
1014 schemaVersion: 1,986 schemaVersion: 1,
1015 drafts: {987 drafts: {
...@@ -1031,26 +1003,33 @@ describe("mobile composer drafts", () => {...@@ -1031,26 +1003,33 @@ describe("mobile composer drafts", () => {
1031 },1003 },
1032 },1004 },
1033 }).drafts,1005 }).drafts,
1034 ).toEqual({1006 );
1035 "new-task:environment-1:project-1": {1007 expect(hydrated).toHaveLength(1);
1036 text: "",1008 const [key, draft] = hydrated[0]!;
1037 attachments: [],1009 // Legacy project keys are rewritten to id keys on load.
1038 modelSelection: {1010 expect(key).toMatch(/^new-task:[0-9a-z-]+$/);
1039 instanceId: "codex",1011 expect(draft).toEqual({
1040 model: "gpt-5.4",1012 text: "",
1041 options: [{ id: "reasoningEffort", value: "xhigh" }],1013 attachments: [],
1042 },1014 modelSelection: {
1043 runtimeMode: "approval-required",1015 instanceId: "codex",
1044 interactionMode: "plan",1016 model: "gpt-5.4",
1045 workspaceSelection: {1017 options: [{ id: "reasoningEffort", value: "xhigh" }],
1046 mode: "worktree",1018 },
1047 branch: "main",1019 runtimeMode: "approval-required",
1048 worktreePath: null,1020 interactionMode: "plan",
1049 },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),
1050 },1030 },
1051 });1031 });
1052 });1032 });
1053
1054 it("keeps legacy content-only drafts and rejects invalid selector state", () => {1033 it("keeps legacy content-only drafts and rejects invalid selector state", () => {
1055 expect(1034 expect(
1056 decodePersistedComposerState({1035 decodePersistedComposerState({
...@@ -1085,7 +1064,7 @@ describe("mobile composer drafts", () => {...@@ -1085,7 +1064,7 @@ describe("mobile composer drafts", () => {
1085 // The stale-model strip must not touch receipt-bearing drafts, and the1064 // The stale-model strip must not touch receipt-bearing drafts, and the
1086 // empty filter must keep them — or the same share would re-import after1065 // empty filter must keep them — or the same share would re-import after
1087 // restart.1066 // restart.
1088 expect(1067 const stripped = Object.values(
1089 decodePersistedComposerState({1068 decodePersistedComposerState({
1090 schemaVersion: 1,1069 schemaVersion: 1,
1091 drafts: {1070 drafts: {
...@@ -1098,20 +1077,146 @@ describe("mobile composer drafts", () => {...@@ -1098,20 +1077,146 @@ describe("mobile composer drafts", () => {
1098 },1077 },
1099 },1078 },
1100 }).drafts,1079 }).drafts,
1101 ).toEqual({1080 );
1102 "new-task:environment-1:project-1": {1081 expect(stripped).toHaveLength(1);
1103 text: "",1082 expect(stripped[0]).toMatchObject({
1104 attachments: [],1083 text: "",
1105 importedShareIds: ["share-1"],1084 attachments: [],
1106 },1085 importedShareIds: ["share-1"],
1086 project: { environmentId: "environment-1", projectId: "project-1" },
1107 });1087 });
1088 expect(stripped[0]?.modelSelection).toBeUndefined();
11081089
1109 expect(1090 const kept = Object.values(
1110 decodePersistedComposerState({1091 decodePersistedComposerState({
1111 schemaVersion: 1,1092 schemaVersion: 1,
1112 drafts: { "new-task:environment-1:project-1": receiptDraft },1093 drafts: { "new-task:environment-1:project-1": receiptDraft },
1113 }).drafts,1094 }).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]);
1115 });1220 });
11161221
1117 it("hydrates the global sticky model selection", () => {1222 it("hydrates the global sticky model selection", () => {
...@@ -1387,56 +1492,7 @@ describe("mobile composer drafts", () => {...@@ -1387,56 +1492,7 @@ describe("mobile composer drafts", () => {
1387 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);1492 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
1388 });1493 });
13891494
1390 it("carries unfinished content to a newly selected project without overwriting its settings", () => {1495 it("drops another environment's upload stamp when a draft moves across machines", () => {
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";
1440 const uploadedElsewhere: DraftComposerAttachment = {1496 const uploadedElsewhere: DraftComposerAttachment = {
1441 id: "image-1",1497 id: "image-1",
1442 type: "image",1498 type: "image",
...@@ -1454,14 +1510,25 @@ describe("mobile composer drafts", () => {...@@ -1454,14 +1510,25 @@ describe("mobile composer drafts", () => {
1454 uploadedAttachmentId: "upload-2",1510 uploadedAttachmentId: "upload-2",
1455 uploadEnvironmentId: EnvironmentId.make("environment-2"),1511 uploadEnvironmentId: EnvironmentId.make("environment-2"),
1456 };1512 };
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(1526 retargetNewTaskDraft(key, {
1459 { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } },1527 environmentId: EnvironmentId.make("environment-2"),
1460 sourceKey,1528 projectId: ProjectId.make("project-2"),
1461 targetKey,1529 });
1462 );
14631530
1464 expect(next[targetKey]?.attachments).toEqual([1531 expect(getComposerDraftSnapshot(key).attachments).toEqual([
1465 {1532 {
1466 id: "image-1",1533 id: "image-1",
1467 type: "image",1534 type: "image",
...@@ -1473,7 +1540,6 @@ describe("mobile composer drafts", () => {...@@ -1473,7 +1540,6 @@ describe("mobile composer drafts", () => {
1473 },1540 },
1474 uploadedOnTarget,1541 uploadedOnTarget,
1475 ]);1542 ]);
1476 expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]);
1477 });1543 });
14781544
1479 it("merges shared content into a project draft without duplicating retries", () => {1545 it("merges shared content into a project draft without duplicating retries", () => {
...@@ -1564,19 +1630,36 @@ describe("mobile composer drafts", () => {...@@ -1564,19 +1630,36 @@ describe("mobile composer drafts", () => {
1564 const environmentId = EnvironmentId.make("environment-cloud");1630 const environmentId = EnvironmentId.make("environment-cloud");
1565 const retainedEnvironmentId = EnvironmentId.make("environment-local");1631 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
1567 expect(1650 expect(
1568 removeComposerDraftsForEnvironment(1651 removeComposerDraftsForEnvironment(
1569 {1652 {
1570 [`${environmentId}:thread-cloud`]: DRAFT,1653 [`${environmentId}:thread-cloud`]: DRAFT,
1571 [`new-task:${environmentId}:project-cloud`]: DRAFT,1654 "new-task:cloud-draft": cloudDraft,
1572 [`${retainedEnvironmentId}:thread-local`]: DRAFT,1655 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
1573 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,1656 "new-task:local-draft": localDraft,
1574 },1657 },
1575 environmentId,1658 environmentId,
1576 ),1659 ),
1577 ).toEqual({1660 ).toEqual({
1578 [`${retainedEnvironmentId}:thread-local`]: DRAFT,1661 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
1579 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,1662 "new-task:local-draft": localDraft,
1580 });1663 });
1581 });1664 });
15821665
apps/mobile/src/state/use-composer-drafts.ts+204-121
...@@ -1,11 +1,14 @@...@@ -1,11 +1,14 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 EnvironmentId as EnvironmentIdSchema,
3 ModelSelection as ModelSelectionSchema,4 ModelSelection as ModelSelectionSchema,
4 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,5 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
6 ProjectId as ProjectIdSchema,
5 ProviderInteractionMode as ProviderInteractionModeSchema,7 ProviderInteractionMode as ProviderInteractionModeSchema,
6 RuntimeMode as RuntimeModeSchema,8 RuntimeMode as RuntimeModeSchema,
7 type EnvironmentId,9 type EnvironmentId,
8 type ModelSelection,10 type ModelSelection,
11 type ProjectId,
9 type ProviderInteractionMode,12 type ProviderInteractionMode,
10 type RuntimeMode,13 type RuntimeMode,
11} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -23,6 +26,11 @@ import {...@@ -23,6 +26,11 @@ import {
23import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";26import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";
24import { SerializedAsyncQueue } from "../lib/serialized-async-queue";27import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
25import { appAtomRegistry } from "./atom-registry";28import { appAtomRegistry } from "./atom-registry";
29import {
30 isNewTaskDraftKey,
31 newTaskDraftKey,
32 parseLegacyNewTaskDraftKey,
33} from "./new-task-draft-key";
26import {34import {
27 decodeQueuedThreadMessage,35 decodeQueuedThreadMessage,
28 encodeQueuedThreadMessage,36 encodeQueuedThreadMessage,
...@@ -59,6 +67,18 @@ export interface ComposerDraft {...@@ -59,6 +67,18 @@ export interface ComposerDraft {
59 readonly runtimeMode?: RuntimeMode;67 readonly runtimeMode?: RuntimeMode;
60 readonly interactionMode?: ProviderInteractionMode;68 readonly interactionMode?: ProviderInteractionMode;
61 readonly workspaceSelection?: ComposerDraftWorkspaceSelection;69 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;
62}82}
6383
64export interface ComposerDraftContent {84export interface ComposerDraftContent {
...@@ -76,7 +96,7 @@ export interface ComposerDraftWorkspaceSelection {...@@ -76,7 +96,7 @@ export interface ComposerDraftWorkspaceSelection {
7696
77export type ComposerDraftSettingsUpdate = Pick<97export type ComposerDraftSettingsUpdate = Pick<
78 ComposerDraft,98 ComposerDraft,
79 "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection"99 "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project"
80>;100>;
81101
82const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({102const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
...@@ -86,6 +106,12 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({...@@ -86,6 +106,12 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
86 startFromOrigin: Schema.optional(Schema.Boolean),106 startFromOrigin: Schema.optional(Schema.Boolean),
87});107});
88108
109const ComposerDraftProjectSchema = Schema.Struct({
110 environmentId: EnvironmentIdSchema,
111 projectId: ProjectIdSchema,
112 createdAt: Schema.String,
113});
114
89const ComposerDraftSchema = Schema.Struct({115const ComposerDraftSchema = Schema.Struct({
90 text: Schema.String,116 text: Schema.String,
91 attachments: Schema.Array(DraftComposerAttachmentSchema),117 attachments: Schema.Array(DraftComposerAttachmentSchema),
...@@ -94,6 +120,7 @@ const ComposerDraftSchema = Schema.Struct({...@@ -94,6 +120,7 @@ const ComposerDraftSchema = Schema.Struct({
94 runtimeMode: Schema.optional(RuntimeModeSchema),120 runtimeMode: Schema.optional(RuntimeModeSchema),
95 interactionMode: Schema.optional(ProviderInteractionModeSchema),121 interactionMode: Schema.optional(ProviderInteractionModeSchema),
96 workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),122 workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),
123 project: Schema.optional(ComposerDraftProjectSchema),
97});124});
98125
99const PersistedComposerDraftsSchema = Schema.Struct({126const PersistedComposerDraftsSchema = Schema.Struct({
...@@ -176,6 +203,8 @@ export function isComposerDraftEmpty(draft: ComposerDraft): boolean {...@@ -176,6 +203,8 @@ export function isComposerDraftEmpty(draft: ComposerDraft): boolean {
176 return isEmptyDraft(draft);203 return isEmptyDraft(draft);
177}204}
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.
179function isEmptyDraft(draft: ComposerDraft): boolean {208function isEmptyDraft(draft: ComposerDraft): boolean {
180 return (209 return (
181 draft.text.length === 0 &&210 draft.text.length === 0 &&
...@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {...@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
187 );216 );
188}217}
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
190export function decodePersistedComposerState(value: unknown): {273export function decodePersistedComposerState(value: unknown): {
191 readonly drafts: Record<string, ComposerDraft>;274 readonly drafts: Record<string, ComposerDraft>;
192 readonly stickyModelSelection: ModelSelection | null;275 readonly stickyModelSelection: ModelSelection | null;
193 readonly cloudDrafts: ComposerCloudDraftState;276 readonly cloudDrafts: ComposerCloudDraftState;
194} {277} {
195 const parsed = decodePersistedComposerDraftsDocument(value);278 const parsed = decodePersistedComposerDraftsDocument(value);
279 const now = new Date().toISOString();
196 return {280 return {
197 drafts: Object.fromEntries(281 drafts: Object.fromEntries(
198 Object.entries(parsed.drafts)282 Object.entries(parsed.drafts)
199 .map(283 .map(([key, draft]) =>
200 ([key, draft]) =>284 migrateLegacyNewTaskDraft(
201 [285 key,
202 key,286 // Stale new-task drafts left on disk by builds before the
203 // Stale new-task drafts left on disk by builds before the287 // model-precedence fix carry a bare modelSelection with no
204 // model-precedence fix carry a bare modelSelection with no288 // other selector settings. Strip it so the next compose pass
205 // other selector settings. Strip it so the next compose pass289 // re-resolves project → sticky → provider defaults. Drafts
206 // re-resolves project → sticky → provider defaults. Drafts290 // with runtime/interaction/workspace settings or actual text /
207 // with runtime/interaction/workspace settings or actual text /291 // attachments were deliberately configured and are left alone.
208 // attachments were deliberately configured and are left alone.292 isNewTaskDraftKey(key) &&
209 key.startsWith("new-task:") &&
210 draft.modelSelection &&293 draft.modelSelection &&
211 draft.text.length === 0 &&294 draft.text.length === 0 &&
212 draft.attachments.length === 0 &&295 draft.attachments.length === 0 &&
213 draft.runtimeMode === undefined &&296 draft.runtimeMode === undefined &&
214 draft.interactionMode === undefined &&297 draft.interactionMode === undefined &&
215 draft.workspaceSelection === undefined298 draft.workspaceSelection === undefined
216 ? { ...draft, modelSelection: undefined }299 ? { ...draft, modelSelection: undefined }
217 : draft,300 : draft,
218 ] as const,301 now,
302 ),
219 )303 )
220 // importedShareIds are share-import receipts: a contentless draft304 // importedShareIds are share-import receipts: a contentless draft
221 // carrying one is not empty, or the same native share would be305 // carrying one is not empty, or the same native share would be
...@@ -229,7 +313,13 @@ export function decodePersistedComposerState(value: unknown): {...@@ -229,7 +313,13 @@ export function decodePersistedComposerState(value: unknown): {
229 Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [313 Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [
230 id,314 id,
231 {315 {
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 ),
233 queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage),323 queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage),
234 },324 },
235 ]),325 ]),
...@@ -622,7 +712,7 @@ export async function archiveCloudComposerDrafts(...@@ -622,7 +712,7 @@ export async function archiveCloudComposerDrafts(
622 const remaining = { ...current };712 const remaining = { ...current };
623 const savedDrafts = { ...cloud.signedOut[owner]?.drafts };713 const savedDrafts = { ...cloud.signedOut[owner]?.drafts };
624 for (const [key, draft] of Object.entries(current)) {714 for (const [key, draft] of Object.entries(current)) {
625 const environmentId = composerDraftEnvironmentId(key, queued);715 const environmentId = composerDraftEnvironmentId(key, queued, draft);
626 if (environmentId !== null && environmentIds.has(environmentId)) {716 if (environmentId !== null && environmentIds.has(environmentId)) {
627 savedDrafts[key] = draft;717 savedDrafts[key] = draft;
628 delete remaining[key];718 delete remaining[key];
...@@ -808,15 +898,7 @@ export function setComposerDraftText(draftKey: string, value: string): void {...@@ -808,15 +898,7 @@ export function setComposerDraftText(draftKey: string, value: string): void {
808 ...normalizeDraft(current[draftKey]),898 ...normalizeDraft(current[draftKey]),
809 text: value,899 text: value,
810 };900 };
811 if (isEmptyDraft(draft)) {901 return withComposerDraft(current, draftKey, draft);
812 const next = { ...current };
813 delete next[draftKey];
814 return next;
815 }
816 return {
817 ...current,
818 [draftKey]: draft,
819 };
820 });902 });
821}903}
822904
...@@ -881,15 +963,7 @@ export function replaceComposerDraftAttachments(...@@ -881,15 +963,7 @@ export function replaceComposerDraftAttachments(
881 ...normalizeDraft(current[draftKey]),963 ...normalizeDraft(current[draftKey]),
882 attachments,964 attachments,
883 };965 };
884 if (isEmptyDraft(draft)) {966 return withComposerDraft(current, draftKey, draft);
885 const next = { ...current };
886 delete next[draftKey];
887 return next;
888 }
889 return {
890 ...current,
891 [draftKey]: draft,
892 };
893 });967 });
894 const retainedIds = new Set(attachments.map((attachment) => attachment.id));968 const retainedIds = new Set(attachments.map((attachment) => attachment.id));
895 scheduleUnusedComposerAttachmentCleanup(969 scheduleUnusedComposerAttachmentCleanup(
...@@ -905,15 +979,7 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)...@@ -905,15 +979,7 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
905 ...existing,979 ...existing,
906 attachments: existing.attachments.filter((image) => image.id !== imageId),980 attachments: existing.attachments.filter((image) => image.id !== imageId),
907 };981 };
908 if (isEmptyDraft(draft)) {982 return withComposerDraft(current, draftKey, draft);
909 const next = { ...current };
910 delete next[draftKey];
911 return next;
912 }
913 return {
914 ...current,
915 [draftKey]: draft,
916 };
917 });983 });
918 scheduleUnusedComposerAttachmentCleanup(984 scheduleUnusedComposerAttachmentCleanup(
919 previousAttachments.filter((attachment) => attachment.id === imageId),985 previousAttachments.filter((attachment) => attachment.id === imageId),
...@@ -964,15 +1030,7 @@ export function updateComposerDraftSettings(...@@ -964,15 +1030,7 @@ export function updateComposerDraftSettings(
964 ...normalizeDraft(current[draftKey]),1030 ...normalizeDraft(current[draftKey]),
965 ...settings,1031 ...settings,
966 };1032 };
967 if (isEmptyDraft(draft)) {1033 return withComposerDraft(current, draftKey, draft);
968 const next = { ...current };
969 delete next[draftKey];
970 return next;
971 }
972 return {
973 ...current,
974 [draftKey]: draft,
975 };
976 });1034 });
977}1035}
9781036
...@@ -988,10 +1046,14 @@ export function clearComposerDraftContentState(...@@ -988,10 +1046,14 @@ export function clearComposerDraftContentState(
988 if (!existing) {1046 if (!existing) {
989 return current;1047 return current;
990 }1048 }
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.
991 const {1052 const {
992 importedShareIds: _importedShareIds,1053 importedShareIds: _importedShareIds,
993 modelSelection,1054 modelSelection,
994 workspaceSelection,1055 workspaceSelection,
1056 project: _project,
995 ...retained1057 ...retained
996 } = existing;1058 } = existing;
997 const draft = {1059 const draft = {
...@@ -1028,49 +1090,6 @@ export function restoreComposerDraftSnapshotState(...@@ -1028,49 +1090,6 @@ export function restoreComposerDraftSnapshotState(
1028 return next;1090 return next;
1029}1091}
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
1074function stripAttachmentUploadReference(1093function stripAttachmentUploadReference(
1075 attachment: DraftComposerAttachment,1094 attachment: DraftComposerAttachment,
1076): DraftComposerAttachment {1095): DraftComposerAttachment {
...@@ -1078,19 +1097,6 @@ function stripAttachmentUploadReference(...@@ -1078,19 +1097,6 @@ function stripAttachmentUploadReference(
1078 return rest;1097 return rest;
1079}1098}
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
1094function mergeComposerDraftText(existing: string, incoming: string): string {1100function mergeComposerDraftText(existing: string, incoming: string): string {
1095 if (incoming.length === 0) {1101 if (incoming.length === 0) {
1096 return existing;1102 return existing;
...@@ -1275,15 +1281,7 @@ export function undoComposerDraftMergeState(...@@ -1275,15 +1281,7 @@ export function undoComposerDraftMergeState(
1275 interactionMode: undoSetting("interactionMode"),1281 interactionMode: undoSetting("interactionMode"),
1276 workspaceSelection: undoSetting("workspaceSelection"),1282 workspaceSelection: undoSetting("workspaceSelection"),
1277 };1283 };
1278 if (isEmptyDraft(draft)) {1284 return withComposerDraft(current, draftKey, draft);
1279 const next = { ...current };
1280 delete next[draftKey];
1281 return next;
1282 }
1283 return {
1284 ...current,
1285 [draftKey]: draft,
1286 };
1287}1285}
12881286
1289/** Applies undoComposerDraftMergeState and lands it durably. */1287/** Applies undoComposerDraftMergeState and lands it durably. */
...@@ -1355,15 +1353,100 @@ export function removeComposerDraftsForEnvironment(...@@ -1355,15 +1353,100 @@ export function removeComposerDraftsForEnvironment(
1355 environmentId: EnvironmentId,1353 environmentId: EnvironmentId,
1356): Record<string, ComposerDraft> {1354): Record<string, ComposerDraft> {
1357 const environmentPrefix = `${environmentId}:`;1355 const environmentPrefix = `${environmentId}:`;
1358 const newTaskPrefix = `new-task:${environmentId}:`;
1359 return Object.fromEntries(1356 return Object.fromEntries(
1360 Object.entries(drafts).filter(1357 Object.entries(drafts).filter(
1361 ([draftKey]) =>1358 ([draftKey, draft]) =>
1362 !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix),1359 !draftKey.startsWith(environmentPrefix) && draft.project?.environmentId !== environmentId,
1363 ),1360 ),
1364 );1361 );
1365}1362}
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
1367export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise<void> {1450export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise<void> {
1368 ensureComposerDraftsLoaded();1451 ensureComposerDraftsLoaded();
1369 if (loadPromise !== null) {1452 if (loadPromise !== null) {
apps/mobile/src/state/use-pending-new-tasks.ts-3
...@@ -20,9 +20,6 @@ export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> {...@@ -20,9 +20,6 @@ export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> {
20 buildPendingNewTasks({20 buildPendingNewTasks({
21 queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey),21 queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey),
22 drafts,22 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(),
26 }),23 }),
27 [queuedMessagesByThreadKey, drafts],24 [queuedMessagesByThreadKey, drafts],
28 );25 );
apps/mobile/src/state/use-thread-outbox-drain.test.ts+9-4
...@@ -582,7 +582,7 @@ describe("thread outbox delivered creation recovery", () => {...@@ -582,7 +582,7 @@ describe("thread outbox delivered creation recovery", () => {
582});582});
583583
584describe("thread outbox recovery rollback", () => {584describe("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 () => {
586 const message: QueuedThreadMessage = {586 const message: QueuedThreadMessage = {
587 ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }),587 ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }),
588 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },588 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
...@@ -599,14 +599,19 @@ describe("thread outbox recovery rollback", () => {...@@ -599,14 +599,19 @@ describe("thread outbox recovery rollback", () => {
599 "restored",599 "restored",
600 );600 );
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.
602 expect(604 expect(
603 composerDrafts.getComposerDraftSnapshot(605 composerDrafts.getComposerDraftSnapshot(`new-task:restored-${message.messageId}`),
604 `new-task:${message.environmentId}:${message.creation!.projectId}`,
605 ),
606 ).toMatchObject({606 ).toMatchObject({
607 text: message.text,607 text: message.text,
608 attachments: message.attachments,608 attachments: message.attachments,
609 modelSelection: message.modelSelection,609 modelSelection: message.modelSelection,
610 project: {
611 environmentId: message.environmentId,
612 projectId: message.creation!.projectId,
613 createdAt: message.createdAt,
614 },
610 });615 });
611 expect(remainingMessages()).toEqual([]);616 expect(remainingMessages()).toEqual([]);
612 expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server");617 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";...@@ -17,7 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
17import { useCallback, useEffect, useRef, useState } from "react";17import { useCallback, useEffect, useRef, useState } from "react";
18import { Alert } from "react-native";18import { Alert } from "react-native";
1919
20import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";20import { scopedThreadKey } from "../lib/scopedEntities";
21import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";21import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
22import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";22import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
23import { randomHex } from "../lib/uuid";23import { randomHex } from "../lib/uuid";
...@@ -53,6 +53,7 @@ import {...@@ -53,6 +53,7 @@ import {
53 type ComposerDraft,53 type ComposerDraft,
54 getComposerDraftSnapshot,54 getComposerDraftSnapshot,
55 mergeComposerDraftContent,55 mergeComposerDraftContent,
56 newTaskDraftKey,
56 replaceComposerDraftAttachments,57 replaceComposerDraftAttachments,
57 removeDeliveredCloudQueuedMessage,58 removeDeliveredCloudQueuedMessage,
58 undoComposerDraftMerge,59 undoComposerDraftMerge,
...@@ -369,6 +370,7 @@ export async function restoreRejectedQueuedMessage(...@@ -369,6 +370,7 @@ export async function restoreRejectedQueuedMessage(
369370
370 let mergedDraft: ComposerDraft;371 let mergedDraft: ComposerDraft;
371 try {372 try {
373 stampRecoveryDraftProject(queuedMessage, draftKey);
372 await mergeComposerDraftContent(draftKey, {374 await mergeComposerDraftContent(draftKey, {
373 text: queuedMessage.text,375 text: queuedMessage.text,
374 attachments: queuedMessage.attachments,376 attachments: queuedMessage.attachments,
...@@ -451,12 +453,31 @@ export async function restoreRejectedQueuedMessage(...@@ -451,12 +453,31 @@ export async function restoreRejectedQueuedMessage(
451 }453 }
452}454}
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 */
454function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string {462function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string {
455 return queuedMessage.creation463 return queuedMessage.creation
456 ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}`464 ? newTaskDraftKey(`restored-${queuedMessage.messageId}`)
457 : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);465 : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);
458}466}
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
460async function preserveUploadedAttachmentsForEditor(481async function preserveUploadedAttachmentsForEditor(
461 originalMessage: QueuedThreadMessage,482 originalMessage: QueuedThreadMessage,
462 uploadedMessage: QueuedThreadMessage,483 uploadedMessage: QueuedThreadMessage,