From 881d43ac0de6812b2e0502ba53a3b66eda0f217c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 13:36:29 +0000 Subject: [PATCH 1/7] test(mobile): reproduce #27616 PR worktree selection lost on machine switch These tests reproduce #27616: retargeting a Work-on-PR new-task draft to another environment's project drops its PR worktree selection, so the round trip back cannot restore it. Co-Authored-By: Claude Opus 5.5 --- .../src/state/use-composer-drafts.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index a10431d802b3d..7e1e052974b1c 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1923,6 +1923,33 @@ describe("mobile composer drafts", () => { expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]); }); + it("keeps a pull request worktree selection when the draft moves between machines and back", () => { + const from = { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + }; + const to = { + environmentId: EnvironmentId.make("environment-2"), + projectId: ProjectId.make("project-2"), + }; + const pullRequestSelection = { + mode: "worktree" as const, + branch: "feature/pr-head", + worktreePath: null, + startFromOrigin: false, + pullRequestReference: "https://github.com/acme/repo/pull/42", + }; + const key = createNewTaskDraft(from); + setComposerDraftText(key, "Review this PR"); + updateComposerDraftSettings(key, { workspaceSelection: pullRequestSelection }); + + retargetNewTaskDraft(key, to); + expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual(pullRequestSelection); + + retargetNewTaskDraft(key, from); + expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual(pullRequestSelection); + }); + it("merges persisted option memory without replacing newer choices", async () => { composerDraftFileMocks.setDocument({ schemaVersion: 1, From 11617248c90bf2867c966f70c95b595d76e6cca5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 13:44:37 +0000 Subject: [PATCH 2/7] fix(mobile): Work on PR keeps its worktree when switching machines Switching a Work-on-PR new-task draft to the same repository on another environment dropped its PR worktree selection, so the task fell back to the current checkout on both machines. The same-repository environment switch now asks the retarget to keep a selection that carries a pull request reference; picking a project still drops it, as does any plain branch selection. Fixes pingdotgg/t3code#27616 Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/new-task-flow-provider.tsx | 11 ++++++++--- apps/mobile/src/state/use-composer-drafts.test.ts | 7 +++++-- apps/mobile/src/state/use-composer-drafts.ts | 9 ++++++++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14a9720cb4e96..5a680e382e885 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -622,11 +622,14 @@ export function NewTaskFlowProvider( replaceComposerDraftAttachments(selectedProjectDraftKey, []); }; - const carryDraftContentTo = (project: EnvironmentProject): string => { + const carryDraftContentTo = ( + project: EnvironmentProject, + options?: Parameters[2], + ): string => { const target = { environmentId: project.environmentId, projectId: project.id }; const current = readFlow().draftKey; if (current !== null && isNewTaskDraftKey(current)) { - retargetNewTaskDraft(current, target); + retargetNewTaskDraft(current, target, options); return current; } return createNewTaskDraft(target); @@ -646,7 +649,9 @@ export function NewTaskFlowProvider( selectedProject, ); updateFlow({ - draftKey: match ? carryDraftContentTo(match) : readFlow().draftKey, + draftKey: match + ? carryDraftContentTo(match, { keepPullRequestWorkspace: true }) + : readFlow().draftKey, environmentId, projectKey: match ? scopedProjectKey(match.environmentId, match.id) : null, }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 7e1e052974b1c..931ed1512643d 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1943,11 +1943,14 @@ describe("mobile composer drafts", () => { setComposerDraftText(key, "Review this PR"); updateComposerDraftSettings(key, { workspaceSelection: pullRequestSelection }); - retargetNewTaskDraft(key, to); + retargetNewTaskDraft(key, to, { keepPullRequestWorkspace: true }); expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual(pullRequestSelection); - retargetNewTaskDraft(key, from); + retargetNewTaskDraft(key, from, { keepPullRequestWorkspace: true }); expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual(pullRequestSelection); + + retargetNewTaskDraft(key, to); + expect(getComposerDraftSnapshot(key).workspaceSelection).toBeUndefined(); }); it("merges persisted option memory without replacing newer choices", async () => { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 791383be9e7a3..125d98f941d54 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1878,6 +1878,7 @@ export function createNewTaskDraft(project: { export function retargetNewTaskDraft( draftKey: string, project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, + options?: { readonly keepPullRequestWorkspace?: boolean }, ): void { updateComposerDrafts((current) => { const existing = current[draftKey]; @@ -1889,7 +1890,12 @@ export function retargetNewTaskDraft( ) { return current; } - const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing); + const { workspaceSelection, ...retained } = normalizeDraft(existing); + // A PR worktree names no local path, so it carries to the same repository on another machine. + const keptWorkspaceSelection = + options?.keepPullRequestWorkspace && workspaceSelection?.pullRequestReference !== undefined + ? { ...workspaceSelection, worktreePath: null } + : undefined; // Keep bytes across environment moves but drop server-specific upload stamps. const attachments = retained.attachments.map((attachment) => attachment.uploadEnvironmentId !== undefined && @@ -1901,6 +1907,7 @@ export function retargetNewTaskDraft( ...current, [draftKey]: { ...retained, + ...(keptWorkspaceSelection ? { workspaceSelection: keptWorkspaceSelection } : {}), attachments, project: { environmentId: project.environmentId, From cf6b09d0749b1349c96a0020b1b4c87fe7c057b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 13:55:52 +0000 Subject: [PATCH 3/7] test(mobile): machine switches still drop plain branch selections Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/use-composer-drafts.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 931ed1512643d..59662e1078b53 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1911,7 +1911,7 @@ describe("mobile composer drafts", () => { }); const createdAt = getComposerDraftSnapshot(key).project?.createdAt; - retargetNewTaskDraft(key, to); + retargetNewTaskDraft(key, to, { keepPullRequestWorkspace: true }); const moved = getComposerDraftSnapshot(key); expect(moved.text).toBe("moving house"); From 6cef806f050e348a586028c70f4b4b5f8d34ed1d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 15:30:27 +0000 Subject: [PATCH 4/7] fix(mobile): carry the PR worktree only to a verified same repository A machine switch can match the target project by folder name or title when its repository identity is unknown; that project may be another repository, so the PR checkout is kept only when both identities match. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/new-task-flow-provider.tsx | 7 +++++-- .../threads/new-task-project-selection.test.ts | 16 ++++++++++++++++ .../threads/new-task-project-selection.ts | 13 +++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5a680e382e885..95120583c0859 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -93,7 +93,7 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; -import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; +import { isSameRepository, resolveEnvironmentProjectMatch } from "./new-task-project-selection"; import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; import { @@ -649,8 +649,11 @@ export function NewTaskFlowProvider( selectedProject, ); updateFlow({ + // A name or title match may be another repository, which must not receive the PR checkout. draftKey: match - ? carryDraftContentTo(match, { keepPullRequestWorkspace: true }) + ? carryDraftContentTo(match, { + keepPullRequestWorkspace: isSameRepository(match, selectedProject), + }) : readFlow().draftKey, environmentId, projectKey: match ? scopedProjectKey(match.environmentId, match.id) : null, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 34357436b6004..fffd6d974b4e8 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -5,6 +5,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { getProjectScopeSelectionTarget, + isSameRepository, newTaskDestination, resolveDraftProjectSelection, resolveEnvironmentProjectMatch, @@ -113,6 +114,21 @@ describe("resolveEnvironmentProjectMatch", () => { expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); }); + + it("vouches for the same repository only when both identities are known", () => { + const selected = makeProject("t3code", "mac", { + repositoryKey: "github.com/t3tools/t3code", + workspaceRoot: "/Users/me/t3code", + }); + const clone = makeProject("clone", "server", { repositoryKey: "github.com/t3tools/t3code" }); + const sameName = makeProject("same-name", "server", { workspaceRoot: "/srv/t3code" }); + expect(isSameRepository(resolveEnvironmentProjectMatch([clone], selected), selected)).toBe( + true, + ); + expect(isSameRepository(resolveEnvironmentProjectMatch([sameName], selected), selected)).toBe( + false, + ); + }); }); describe("resolveDraftProjectSelection", () => { diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 99167a04e386d..616325fb9b065 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -88,6 +88,19 @@ export function resolveEnvironmentProjectMatch( ); } +/** Both projects have a known repository identity, and it is the same one. */ +export function isSameRepository( + left: EnvironmentProject | null, + right: EnvironmentProject | null, +): boolean { + return ( + left?.repositoryIdentity != null && + right?.repositoryIdentity != null && + repositoryGroupingKeyOf(left.repositoryIdentity) === + repositoryGroupingKeyOf(right.repositoryIdentity) + ); +} + export function resolveDraftProjectSelection( selectedProjectKey: string | null, projects: ReadonlyArray, From 9f3176e22c9a5dbe048ab6ee5b451917b1f10e99 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 15:34:56 +0000 Subject: [PATCH 5/7] test(mobile): fold the PR workspace and identity cases into existing tests Co-Authored-By: Claude Opus 5.5 --- .../new-task-project-selection.test.ts | 17 ++------------ .../src/state/use-composer-drafts.test.ts | 23 ++++--------------- 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index fffd6d974b4e8..7c15866ce6d4a 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -83,6 +83,7 @@ describe("resolveEnvironmentProjectMatch", () => { makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }), ]; expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); + expect(isSameRepository(target[1]!, selected)).toBe(true); }); it("falls back to workspace basename, then title, for unindexed projects", () => { @@ -92,6 +93,7 @@ describe("resolveEnvironmentProjectMatch", () => { makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), ]; expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); + expect(isSameRepository(byBasename[1]!, selected)).toBe(false); const byTitle = [ makeProject("other", "server"), @@ -114,21 +116,6 @@ describe("resolveEnvironmentProjectMatch", () => { expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); }); - - it("vouches for the same repository only when both identities are known", () => { - const selected = makeProject("t3code", "mac", { - repositoryKey: "github.com/t3tools/t3code", - workspaceRoot: "/Users/me/t3code", - }); - const clone = makeProject("clone", "server", { repositoryKey: "github.com/t3tools/t3code" }); - const sameName = makeProject("same-name", "server", { workspaceRoot: "/srv/t3code" }); - expect(isSameRepository(resolveEnvironmentProjectMatch([clone], selected), selected)).toBe( - true, - ); - expect(isSameRepository(resolveEnvironmentProjectMatch([sameName], selected), selected)).toBe( - false, - ); - }); }); describe("resolveDraftProjectSelection", () => { diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 59662e1078b53..1d87f253b006a 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1890,7 +1890,7 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(second).text).toBe("second idea"); }); - it("retargets a new-task draft to another project without losing its text", () => { + it("retargets a new-task draft to another project, keeping only a pull request workspace", () => { const from = { environmentId: EnvironmentId.make("environment-1"), projectId: ProjectId.make("project-1"), @@ -1921,17 +1921,7 @@ describe("mobile composer drafts", () => { expect(moved.project).toEqual({ ...to, createdAt }); expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]); expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]); - }); - it("keeps a pull request worktree selection when the draft moves between machines and back", () => { - const from = { - environmentId: EnvironmentId.make("environment-1"), - projectId: ProjectId.make("project-1"), - }; - const to = { - environmentId: EnvironmentId.make("environment-2"), - projectId: ProjectId.make("project-2"), - }; const pullRequestSelection = { mode: "worktree" as const, branch: "feature/pr-head", @@ -1939,17 +1929,12 @@ describe("mobile composer drafts", () => { startFromOrigin: false, pullRequestReference: "https://github.com/acme/repo/pull/42", }; - const key = createNewTaskDraft(from); - setComposerDraftText(key, "Review this PR"); updateComposerDraftSettings(key, { workspaceSelection: pullRequestSelection }); - - retargetNewTaskDraft(key, to, { keepPullRequestWorkspace: true }); - expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual(pullRequestSelection); - retargetNewTaskDraft(key, from, { keepPullRequestWorkspace: true }); expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual(pullRequestSelection); - - retargetNewTaskDraft(key, to); + retargetNewTaskDraft(key, to, { keepPullRequestWorkspace: true }); + expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual(pullRequestSelection); + retargetNewTaskDraft(key, from); expect(getComposerDraftSnapshot(key).workspaceSelection).toBeUndefined(); }); From 63d02cd849afcc565380bb56b924d2bdb34fecc5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 15:46:27 +0000 Subject: [PATCH 6/7] test(mobile): a different known repository is not the same repository Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/new-task-project-selection.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 7c15866ce6d4a..a5b0542e53e8a 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -115,6 +115,7 @@ describe("resolveEnvironmentProjectMatch", () => { const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); + expect(isSameRepository(fork, selected)).toBe(false); }); }); From 0e9c343f8e8302a6bb915b569d19e96ba2329b32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:05:44 +0000 Subject: [PATCH 7/7] test(mobile): a carried PR selection drops the old machine's worktree path AI-assisted; verified with the tests and tools listed above. --- apps/mobile/src/state/use-composer-drafts.test.ts | 11 ++++++----- apps/mobile/src/state/use-composer-drafts.ts | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 286d0670235af..2ea8e6d0a9921 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1935,16 +1935,17 @@ describe("mobile composer drafts", () => { const pullRequestSelection = { mode: "worktree" as const, branch: "feature/pr-head", - worktreePath: null, + worktreePath: "/old-machine/worktrees/pr-42", startFromOrigin: false, pullRequestReference: "https://github.com/acme/repo/pull/42", }; updateComposerDraftSettings(key, { workspaceSelection: pullRequestSelection }); retargetNewTaskDraft(key, from, { keepPullRequestWorkspace: true }); - expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual(pullRequestSelection); - retargetNewTaskDraft(key, to, { keepPullRequestWorkspace: true }); - expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual(pullRequestSelection); - retargetNewTaskDraft(key, from); + expect(getComposerDraftSnapshot(key).workspaceSelection).toEqual({ + ...pullRequestSelection, + worktreePath: null, + }); + retargetNewTaskDraft(key, to); expect(getComposerDraftSnapshot(key).workspaceSelection).toBeUndefined(); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 601f5f6f4ab81..584f489d426c9 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1978,7 +1978,7 @@ export function retargetNewTaskDraft( return current; } const { workspaceSelection, ...retained } = normalizeDraft(existing); - // A PR worktree names no local path, so it carries to the same repository on another machine. + // The PR checkout is recreated on the target machine, so only the old local path is dropped. const keptWorkspaceSelection = options?.keepPullRequestWorkspace && workspaceSelection?.pullRequestReference !== undefined ? { ...workspaceSelection, worktreePath: null }