feat(mobile): allow several new-task drafts per project#10327
6 unresolved threads ยท Jump to file
- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx:438 ยท ๐ก **Medium** `threads/NewTaskDraftScreen.tsx:438` On a cold launch, a persisted `draftId` is treat
- apps/mobile/src/features/threads/new-task-flow-provider.tsx:656 ยท ๐ **High** `threads/new-task-flow-provider.tsx:656` Opening an orphaned draft submits its content
- apps/mobile/src/state/use-composer-drafts.ts:243 ยท ๐ **High** `state/use-composer-drafts.ts:243` Signing out of a relay environment permanently drops
- apps/mobile/src/state/use-composer-drafts.ts:243 ยท ๐ **High** `state/use-composer-drafts.ts:243` New drafts created by `createNewTaskDraft` cannot be
- apps/mobile/src/state/use-composer-drafts.ts:284 ยท ๐ **High** `state/use-composer-drafts.ts:284` Legacy `new-task:<environmentId>:<projectId>` drafts
- apps/mobile/src/features/threads/new-task-flow-provider.tsx ยท File comment ยท ๐ **High** https://github.com/pingdotgg/t3code/blob/192f4a8a263f070c312c24f9d20a26e42a0a393a/apps/
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx ยท Earlier revision 192f4a8a ยท right 438
Original diff
@@ -420,7 +422,24 @@ export function NewTaskDraftScreen(props: {
};
}, []);
- const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow;
+ const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow;
+ // A Draft row opens its own draft; a fresh New Task never reuses one.
+ // Attempt each id once so a draft that was discarded while this screen is
+ // mounted does not keep bouncing to the picker.
+ const attemptedDraftIdRef = useRef<string | null>(null);
+ useEffect(() => {
+ if (!props.draftId || props.pendingTaskId) {
+ return;
+ }
+ if (attemptedDraftIdRef.current === props.draftId) {
+ return;
+ }
+ attemptedDraftIdRef.current = props.draftId;
+ if (!openDraft(props.draftId)) {๐ก Medium threads/NewTaskDraftScreen.tsx:438
On a cold launch, a persisted draftId is treated as missing and the route is immediately replaced with a fresh NewTask, so the existing draft cannot be opened. openDraft runs before asynchronous composer-draft hydration completes, and attemptedDraftIdRef prevents retrying once the draft arrives; wait for hydration before calling openDraft (or retry after it completes).
๐ Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/mobile/src/features/threads/NewTaskDraftScreen.tsx around line 438: |
| 2 | |
| 3 | On a cold launch, a persisted `draftId` is treated as missing and the route is immediately replaced with a fresh `NewTask`, so the existing draft cannot be opened. `openDraft` runs before asynchronous composer-draft hydration completes, and `attemptedDraftIdRef` prevents retrying once the draft arrives; wait for hydration before calling `openDraft` (or retry after it completes). |
Confirmed and fixed in 1e836229f. The draft screen now awaits waitForComposerDraftsLoaded() before calling openDraft, and if the draft exists but the project list is still empty it retries on the next projects change instead of replacing the route. Only once hydration is done and the draft or its project is genuinely absent does it fall back to the picker.
apps/mobile/src/features/threads/new-task-flow-provider.tsx ยท Earlier revision 192f4a8a ยท right 656
Original diff
@@ -625,19 +638,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const setProject = useCallback(
(project: EnvironmentProject) => {
const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
- const nextDraftKey = `new-task:${nextProjectKey}`;
- if (
- selectedProjectDraftKey?.startsWith("new-task:") &&
- selectedProjectDraftKey !== nextDraftKey
- ) {
- void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
+ const target = { environmentId: project.environmentId, projectId: project.id };
+ // Switching projects mid-compose keeps the same draft and moves it, so
+ // typed text follows the user; a pending-task edit owns its own key and
+ // is untouched here.
+ if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) {
+ retargetNewTaskDraft(activeDraftKey, target);
+ } else if (!editingPendingTaskRef.current) {
+ setActiveDraftKey(createNewTaskDraft(target));
}
setSelectedEnvironmentId(project.environmentId);
setSelectedProjectKey(nextProjectKey);
},
- [selectedProjectDraftKey],
+ [activeDraftKey],
);
+ const openDraft = useCallback((draftKey: string): boolean => {๐ High threads/new-task-flow-provider.tsx:656
Opening an orphaned draft submits its content to projectsForEnvironment[0], so a deleted or temporarily unloaded stamped project is silently replaced by an unrelated project. openDraft only checks the draft stamp; reject the draft when no matching project exists so the caller returns to the picker instead.
Also found in 2 other location(s)
apps/mobile/src/features/home/usePendingTaskListActions.ts:25
Passing
draftIdmakes the draft screen bypass its route-project availability fallback. If the draft's stamped project was deleted or is no longer loaded,openDraftsets an unavailable key; the flow then falls back toprojectsForEnvironment[0]while retaining the draft's content, so submitting this row starts the task in an unrelated first project instead of returning to the picker.
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx:438
This accepts any persisted draft whose
projectstamp exists, even when that stamped project is no longer present inprojects.openDraftsets the missing project's key, but the flow resolvesselectedProjectby falling back toprojectsForEnvironment[0]; meanwhile it still reads the opened draft throughactiveDraftKey. Thus opening a draft after its project is removed or temporarily absent can display and submit that draft (including attachments) to the first unrelated project in the same environment.
๐ Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around line 656: |
| 2 | |
| 3 | Opening an orphaned draft submits its content to `projectsForEnvironment[0]`, so a deleted or temporarily unloaded stamped project is silently replaced by an unrelated project. `openDraft` only checks the draft stamp; reject the draft when no matching project exists so the caller returns to the picker instead. |
| 4 | |
| 5 | Also found in 2 other location(s): |
| 6 | - apps/mobile/src/features/home/usePendingTaskListActions.ts:25 -- Passing `draftId` makes the draft screen bypass its route-project availability fallback. If the draft's stamped project was deleted or is no longer loaded, `openDraft` sets an unavailable key; the flow then falls back to `projectsForEnvironment[0]` while retaining the draft's content, so submitting this row starts the task in an unrelated first project instead of returning to the picker. |
| 7 | - apps/mobile/src/features/threads/NewTaskDraftScreen.tsx:438 -- This accepts any persisted draft whose `project` stamp exists, even when that stamped project is no longer present in `projects`. `openDraft` sets the missing project's key, but the flow resolves `selectedProject` by falling back to `projectsForEnvironment[0]`; meanwhile it still reads the opened draft through `activeDraftKey`. Thus opening a draft after its project is removed or temporarily absent can display and submit that draft (including attachments) to the first unrelated project in the same environment. |
Confirmed and fixed in 1e836229f. openDraft now returns false when the draft's stamped project is not in the loaded project list, so the draft screen falls back to the picker instead of the composer binding to projectsForEnvironment[0]. Combined with the hydration wait above, a temporarily unloaded project retries rather than misrouting.
apps/mobile/src/state/use-composer-drafts.ts ยท Earlier revision 192f4a8a ยท right 243
Original diff
@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
);
}
+/**
+ * Writes a draft back, dropping it once empty. A new-task draft keeps its
+ * entry while the composer is bound to it (the project stamp is what the
+ * composer binds to); the persist sweep still leaves empty ones off disk.
+ */
+function withComposerDraft(
+ current: Record<string, ComposerDraft>,
+ draftKey: string,
+ draft: ComposerDraft,
+): Record<string, ComposerDraft> {
+ if (isEmptyDraft(draft) && draft.project === undefined) {
+ const next = { ...current };
+ delete next[draftKey];
+ return next;
+ }
+ return { ...current, [draftKey]: draft };
+}
+
+export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key";
+
+// Draft ids only need to be unique within this device's draft file. Deriving
+// them from time plus randomness keeps this module free of native imports,
+// which the persistence tests rely on.
+function newDraftId(): string {
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;๐ High state/use-composer-drafts.ts:243
Signing out of a relay environment permanently drops every unsent new-task draft created here. newTaskDraftKey(newDraftId()) produces a key containing only the timestamp and random suffix, so archiveCloudComposerDrafts cannot recover the environment via composerDraftEnvironmentId(key, queued) and skips the draft; cleanup then deletes it using project.environmentId. Include the environment in new-task keys (or otherwise make it recoverable by composerDraftEnvironmentId).
๐ Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/mobile/src/state/use-composer-drafts.ts around line 243: |
| 2 | |
| 3 | Signing out of a relay environment permanently drops every unsent new-task draft created here. `newTaskDraftKey(newDraftId())` produces a key containing only the timestamp and random suffix, so `archiveCloudComposerDrafts` cannot recover the environment via `composerDraftEnvironmentId(key, queued)` and skips the draft; cleanup then deletes it using `project.environmentId`. Include the environment in new-task keys (or otherwise make it recoverable by `composerDraftEnvironmentId`). |
Confirmed and fixed in 1e836229f, same root cause as the upload finding: archiveCloudComposerDrafts now resolves the environment from the draft's project stamp, so id-keyed drafts are archived with their environment instead of being skipped and then swept.
apps/mobile/src/state/use-composer-drafts.ts ยท Earlier revision 192f4a8a ยท right 243
Original diff
@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
);
}
+/**
+ * Writes a draft back, dropping it once empty. A new-task draft keeps its
+ * entry while the composer is bound to it (the project stamp is what the
+ * composer binds to); the persist sweep still leaves empty ones off disk.
+ */
+function withComposerDraft(
+ current: Record<string, ComposerDraft>,
+ draftKey: string,
+ draft: ComposerDraft,
+): Record<string, ComposerDraft> {
+ if (isEmptyDraft(draft) && draft.project === undefined) {
+ const next = { ...current };
+ delete next[draftKey];
+ return next;
+ }
+ return { ...current, [draftKey]: draft };
+}
+
+export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key";
+
+// Draft ids only need to be unique within this device's draft file. Deriving
+// them from time plus randomness keeps this module free of native imports,
+// which the persistence tests rely on.
+function newDraftId(): string {
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;๐ High state/use-composer-drafts.ts:243
New drafts created by createNewTaskDraft cannot be associated with an environment by composerDraftEnvironmentId, so attachment uploads never start and connected drafts are permanently blocked as Attachment still uploading. newDraftId() supplies only a time/random token, while the worker expects the new-task:<environmentId>:<projectId> key format; include the environment and project segments in the created key (or otherwise provide that metadata to the worker).
๐ Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/mobile/src/state/use-composer-drafts.ts around line 243: |
| 2 | |
| 3 | New drafts created by `createNewTaskDraft` cannot be associated with an environment by `composerDraftEnvironmentId`, so attachment uploads never start and connected drafts are permanently blocked as `Attachment still uploading`. `newDraftId()` supplies only a time/random token, while the worker expects the `new-task:<environmentId>:<projectId>` key format; include the environment and project segments in the created key (or otherwise provide that metadata to the worker). |
Confirmed and fixed in 1e836229f. composerDraftEnvironmentId now takes the draft and reads project.environmentId from the stamp; the legacy key parse only applies to drafts that have not been migrated yet. All three callers (upload sync, upload persistence, sign-out archival) pass the draft. Covered by a new case in composerAttachmentUploadQueue.test.ts.
apps/mobile/src/state/use-composer-drafts.ts ยท Earlier revision 192f4a8a ยท right 284
Original diff
@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
);
}
+/**
+ * Writes a draft back, dropping it once empty. A new-task draft keeps its
+ * entry while the composer is bound to it (the project stamp is what the
+ * composer binds to); the persist sweep still leaves empty ones off disk.
+ */
+function withComposerDraft(
+ current: Record<string, ComposerDraft>,
+ draftKey: string,
+ draft: ComposerDraft,
+): Record<string, ComposerDraft> {
+ if (isEmptyDraft(draft) && draft.project === undefined) {
+ const next = { ...current };
+ delete next[draftKey];
+ return next;
+ }
+ return { ...current, [draftKey]: draft };
+}
+
+export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key";
+
+// Draft ids only need to be unique within this device's draft file. Deriving
+// them from time plus randomness keeps this module free of native imports,
+// which the persistence tests rely on.
+function newDraftId(): string {
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+}
+
+/**
+ * Project-keyed new-task drafts from earlier builds are rewritten on load into
+ * id-keyed drafts with the project stamped in, so existing drafts survive the
+ * switch to many-per-project.
+ */
+export function migrateLegacyNewTaskDraft(
+ key: string,
+ draft: ComposerDraft,
+ now: string,
+): readonly [key: string, draft: ComposerDraft] {
+ const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null;
+ if (legacy === null) {
+ return [key, draft];
+ }
+ return [
+ newTaskDraftKey(newDraftId()),
+ {
+ ...draft,
+ project: {
+ environmentId: EnvironmentIdSchema.make(legacy.environmentId),
+ projectId: ProjectIdSchema.make(legacy.projectId),
+ createdAt: now,
+ },
+ },
+ ];
+}
+
export function decodePersistedComposerState(value: unknown): {
readonly drafts: Record<string, ComposerDraft>;
readonly stickyModelSelection: ModelSelection | null;
readonly cloudDrafts: ComposerCloudDraftState;
} {
const parsed = decodePersistedComposerDraftsDocument(value);
+ const now = new Date().toISOString();
return {
drafts: Object.fromEntries(
Object.entries(parsed.drafts)
- .map(
- ([key, draft]) =>
- [
- key,
- // Stale new-task drafts left on disk by builds before the
- // model-precedence fix carry a bare modelSelection with no
- // other selector settings. Strip it so the next compose pass
- // re-resolves project โ sticky โ provider defaults. Drafts
- // with runtime/interaction/workspace settings or actual text /
- // attachments were deliberately configured and are left alone.
- key.startsWith("new-task:") &&
+ .map(([key, draft]) =>
+ migrateLegacyNewTaskDraft(๐ High state/use-composer-drafts.ts:284
Legacy new-task:<environmentId>:<projectId> drafts inside signedOutDrafts[*].drafts are restored without a project stamp, so buildPendingNewTasks excludes them and openDraft rejects them, making the archived content inaccessible. The same drafts also evade environment cleanup because their ownership is never migrated; apply migrateLegacyNewTaskDraft to each signed-out account's drafts before storing them.
๐ Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/mobile/src/state/use-composer-drafts.ts around line 284: |
| 2 | |
| 3 | Legacy `new-task:<environmentId>:<projectId>` drafts inside `signedOutDrafts[*].drafts` are restored without a `project` stamp, so `buildPendingNewTasks` excludes them and `openDraft` rejects them, making the archived content inaccessible. The same drafts also evade environment cleanup because their ownership is never migrated; apply `migrateLegacyNewTaskDraft` to each signed-out account's drafts before storing them. |
Confirmed and fixed in 1e836229f. decodePersistedComposerState now runs migrateLegacyNewTaskDraft over each signed-out account's drafts as well as the live ones. Test: "migrates archived signed-out new-task drafts the same way as live ones".
apps/mobile/src/features/threads/new-task-flow-provider.tsx ยท File comment
๐ High
Switching environments while composing sends the draft using the matched project but leaves its project stamp and workspace selection tied to the old project, so the draft is later shown under the wrong project and may retain an invalid old-machine worktree path. selectEnvironment changes selectedProjectKey without retargeting activeDraftKey; retarget the active new-task draft to match before updating the selection.
๐ Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around line 692: |
| 2 | |
| 3 | Switching environments while composing sends the draft using the matched project but leaves its `project` stamp and workspace selection tied to the old project, so the draft is later shown under the wrong project and may retain an invalid old-machine worktree path. `selectEnvironment` changes `selectedProjectKey` without retargeting `activeDraftKey`; retarget the active new-task draft to `match` before updating the selection. |
Confirmed and fixed in 1e836229f. selectEnvironment now retargets the active new-task draft to the matched project (which also clears the old machine's workspace selection), mirroring what setProject does.