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

merged3
t3code/mobile-drafts-in-list
t3code/mobile-multi-draft
openedSep 6, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago

Stacked on #10260 (which is stacked on #10245).

Problem

Mobile keyed new-task drafts by project (new-task:<environmentId>:<projectId>), one slot per project. Starting a new task in a project always landed in whatever draft already sat there, so there was no way to hold two ideas for the same repo. Web mints a draft id per draft and never had this limit. With #10260 listing drafts in the thread list, the single slot became visible.

Fix

Drafts are keyed new-task:<draftId> and carry their project in a project stamp (environmentId, projectId, createdAt). Everything that used to read the project out of the key now reads the stamp:

  • Migration. Legacy project-keyed entries are rewritten to id keys with the stamp filled in when the draft file loads, for live drafts and for drafts archived by sign-out, so nothing on users' devices is lost.
  • New Task mints a fresh draft. Each entry into the composer binds to its own draft, whether it came from the compose button, a project row, or the provider's project fallback. Tapping a Draft row resumes that draft by id through a new draftId route param; the screen waits for draft hydration and for the project list before deciding a draft is gone.
  • Switching projects or environments retargets the same draft (text and model/mode choices follow; branch and worktree are cleared since they belong to the old repo) instead of copy-if-empty across keys. copyComposerDraftContent* is removed.
  • Uploads and sign-out resolve a draft's environment from the stamp (composerDraftEnvironmentId now takes the draft), so id-keyed drafts upload attachments and survive relay sign-out.
  • A rejected pending task restores into its own message-keyed draft stamped with the project, rather than merging into whatever the user is typing.
  • Drafts list newest first by their own createdAt instead of a render-time stamp.

Verification

apps/mobile: vp test run across the draft store, list model, outbox drain/removal/editor, attachment upload queue, and thread-list suites โ€” 239 passed. vpr typecheck passes. Lint on touched files shows only pre-existing warnings.

New tests: legacy key migration (live and archived), minting several drafts per project, retarget keeps text and clears workspace selection, environment lookup from the stamp, rejected-task restore into a stamped draft.

Before / after โ€” starting a fresh New Task in a project that already has a draft

Before: the composer opens with the existing draft text "First idea, typed earlier" already in it. After: the composer opens empty; the earlier draft is kept separately

After โ€” three drafts for the same project in the thread list

One migrated from a legacy project key, one already id-keyed, and one typed live and retargeted across environments. Tapping any row opens that draft.

Three Draft rows for pingdotgg/t3code above the Sends on reconnect row

Captured on an iPhone 17 Pro simulator with the dev client against a disposable backend; the "before" is the head of #10260 with the same seeded state. Captures were taken before the stack was rebased onto current main (which retinted the Pending pill and reworked carryDraftContentTo); the rebase changed no draft behaviour shown here.

Surfaces

  • Entry points: compose button, project row "new thread", Draft rows (v1 and v2 lists, Home and iPad sidebar), pending-task restore.
  • Clients: mobile only. Web already has per-draft ids; server untouched.
  • Reverse state: Discard from the row menu, or clear the composer; both drop the stamp so the row disappears.

Model: Claude Fable 5 ยท Harness: Claude Code in T3 Code

๐Ÿค– Generated with Claude Code

Synced from GitHub

Thread transfer impact

โœ… Thread transfer remains within every enforced ceiling.

โ„น๏ธ No successful main baseline artifact is available yet. This run establishes the initial measurement.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wireโ€”13.6 KiBโ€”15.1 KiBโœ…
CodexThread snapshot wireโ€”7.0 KiBโ€”7.3 KiBโœ…
CodexLive turn WebSocket wireโ€”6.5 KiBโ€”7.8 KiBโœ…
CodexLive turn WebSocket decodedโ€”57.0 KiBโ€”66.4 KiBโœ…
CodexLive turn messagesโ€”8โ€”21โœ…
ClaudeTotal thread wireโ€”13.6 KiBโ€”15.1 KiBโœ…
ClaudeThread snapshot wireโ€”7.0 KiBโ€”7.3 KiBโœ…
ClaudeLive turn WebSocket wireโ€”6.5 KiBโ€”7.8 KiBโœ…
ClaudeLive turn WebSocket decodedโ€”57.8 KiBโ€”66.4 KiBโœ…
ClaudeLive turn messagesโ€”8โ€”21โœ…

Baseline: unavailable ยท PR result: c1157a3 ยท Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 113.9 KiB
  • Claude decoded thread snapshot: 114.6 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

apps/mobile/src/features/threads/NewTaskDraftScreen.tsxline 438Outdated ยท 192f4a8Open thread
โ€ฆโ€ฆ@@ -420,7 +422,24 @@ export function NewTaskDraftScreen(props: {
435+ return;
436+ }
437+ attemptedDraftIdRef.current = props.draftId;
438+ if (!openDraft(props.draftId)) {
MmacroscopeappBoton line 438Sep 6, 2026

๐ŸŸก 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:
text
1In file @apps/mobile/src/features/threads/NewTaskDraftScreen.tsx around line 438:
2
3On 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.tsxline 656Outdated ยท 192f4a8Open thread
โ€ฆโ€ฆ@@ -625,19 +638,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
653+ [activeDraftKey],
639654 );
640655
656+ const openDraft = useCallback((draftKey: string): boolean => {
MmacroscopeappBoton line 656Sep 6, 2026

๐ŸŸ  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 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.

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.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around line 656:
2
3Opening 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
5Also 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.tsline 243Outdated ยท 192f4a8Open thread
โ€ฆโ€ฆ@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
240+// them from time plus randomness keeps this module free of native imports,
241+// which the persistence tests rely on.
242+function newDraftId(): string {
243+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
MmacroscopeappBoton line 243Sep 6, 2026

๐ŸŸ  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:
text
1In file @apps/mobile/src/state/use-composer-drafts.ts around line 243:
2
3Signing 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.tsline 243Outdated ยท 192f4a8Open thread
โ€ฆโ€ฆ@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
240+// them from time plus randomness keeps this module free of native imports,
241+// which the persistence tests rely on.
242+function newDraftId(): string {
243+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
MmacroscopeappBoton line 243Sep 6, 2026

๐ŸŸ  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:
text
1In file @apps/mobile/src/state/use-composer-drafts.ts around line 243:
2
3New 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.tsline 284Outdated ยท 192f4a8Open thread
โ€ฆโ€ฆ@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
208- // attachments were deliberately configured and are left alone.
209- key.startsWith("new-task:") &&
283+ .map(([key, draft]) =>
284+ migrateLegacyNewTaskDraft(
MmacroscopeappBoton line 284Sep 6, 2026

๐ŸŸ  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:
text
1In file @apps/mobile/src/state/use-composer-drafts.ts around line 284:
2
3Legacy `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".

MmacroscopeappBoton fileSep 6, 2026

๐ŸŸ  High

https://github.com/pingdotgg/t3code/blob/192f4a8a263f070c312c24f9d20a26e42a0a393a/apps/mobile/src/features/threads/new-task-flow-provider.tsx#L692

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:
text
1In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around line 692:
2
3Switching 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.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable โ€” This PR changes mobile draft identity and persistence from one project slot to multiple ID-keyed drafts, adding migration, route resumption, project retargeting, attachment scoping, and recovery behavior across several production paths. The cross-cutting state and persisted-data changes have a broad runtime surface and warrant human review.

Not approved because:

  • Monthly spending limit reached (workspace setting). Approvability relies on correctness review in order to determine eligibility

Review your spending limits in Billing settings. You can add or adjust custom eligibility rules. Learn more.

Added 3 commits

  • feat(mobile): allow several new-task drafts per project22c1f67
  • fix(mobile): keep id-keyed drafts owned by their environment and project5d256e4
  • fix(mobile): bind a new-task draft whenever a project is in viewc1157a3
Synced from GitHub

Bugbot is paused โ€” on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.