authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 20:12:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 20:12:51-07:00
logbfba7781681eaa03eb465ce3d9a4ec07bf952b78
tree0fed6105f1ebf536c52dc6143b149ed2639aec7f
parentb438447f67b6b61bbe6f564d8b78fd90702117c5
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep the new-task draft when switching environment (#10247)

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

5 files changed, 202 insertions(+), 33 deletions(-)

apps/mobile/src/features/threads/new-task-flow-provider.tsx+22-28
...@@ -92,6 +92,7 @@ import {...@@ -92,6 +92,7 @@ import {
92 resolveNewTaskBranchWorktreePath,92 resolveNewTaskBranchWorktreePath,
93 resolveNewTaskLocalWorkspaceSelection,93 resolveNewTaskLocalWorkspaceSelection,
94} from "./new-task-context-presentation";94} from "./new-task-context-presentation";
95import { resolveEnvironmentProjectMatch } from "./new-task-project-selection";
9596
96type WorkspaceMode = "local" | "worktree";97type WorkspaceMode = "local" | "worktree";
9798
...@@ -622,51 +623,44 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -622,51 +623,44 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
622 );623 );
623 }, [availableBranches, branchQuery]);624 }, [availableBranches, branchQuery]);
624625
625 const setProject = useCallback(626 // New-task drafts are keyed per (environment, project), so retargeting the
627 // composer would otherwise show the target's empty draft and strand what the
628 // user typed under the old key.
629 const carryDraftContentTo = useCallback(
626 (project: EnvironmentProject) => {630 (project: EnvironmentProject) => {
627 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);631 const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`;
628 const nextDraftKey = `new-task:${nextProjectKey}`;
629 if (632 if (
630 selectedProjectDraftKey?.startsWith("new-task:") &&633 selectedProjectDraftKey?.startsWith("new-task:") &&
631 selectedProjectDraftKey !== nextDraftKey634 selectedProjectDraftKey !== nextDraftKey
632 ) {635 ) {
633 void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);636 void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
634 }637 }
635 setSelectedEnvironmentId(project.environmentId);
636 setSelectedProjectKey(nextProjectKey);
637 },638 },
638 [selectedProjectDraftKey],639 [selectedProjectDraftKey],
639 );640 );
640641
642 const setProject = useCallback(
643 (project: EnvironmentProject) => {
644 carryDraftContentTo(project);
645 setSelectedEnvironmentId(project.environmentId);
646 setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id));
647 },
648 [carryDraftContentTo],
649 );
650
641 const selectEnvironment = useCallback(651 const selectEnvironment = useCallback(
642 (environmentId: EnvironmentId) => {652 (environmentId: EnvironmentId) => {
643 const projectsOnTarget = projects.filter(653 const match = resolveEnvironmentProjectMatch(
644 (project) => project.environmentId === environmentId,654 projects.filter((project) => project.environmentId === environmentId),
655 selectedProject,
645 );656 );
646 const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;657 if (match) {
647 // Prefer the repository identity; projects without one (e.g. not yet658 carryDraftContentTo(match);
648 // indexed) fall back to workspace basename, then title, so switching659 }
649 // computers still follows the same repo instead of resetting to
650 // whatever project is first on the target machine.
651 const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
652 const match =
653 (repositoryKey !== null
654 ? projectsOnTarget.find(
655 (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey,
656 )
657 : undefined) ??
658 (workspaceBasename !== null
659 ? projectsOnTarget.find(
660 (project) => project.workspaceRoot.split("/").at(-1) === workspaceBasename,
661 )
662 : undefined) ??
663 (selectedProject !== null
664 ? projectsOnTarget.find((project) => project.title === selectedProject.title)
665 : undefined);
666 setSelectedEnvironmentId(environmentId);660 setSelectedEnvironmentId(environmentId);
667 setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null);661 setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null);
668 },662 },
669 [projects, selectedProject],663 [projects, selectedProject, carryDraftContentTo],
670 );664 );
671665
672 const setWorkspaceMode = useCallback(666 const setWorkspaceMode = useCallback(
apps/mobile/src/features/threads/new-task-project-selection.test.ts+71-4
...@@ -6,15 +6,33 @@ import type { HomeProjectScope } from "../home/homeThreadList";...@@ -6,15 +6,33 @@ import type { HomeProjectScope } from "../home/homeThreadList";
6import {6import {
7 getProjectScopeSelectionTarget,7 getProjectScopeSelectionTarget,
8 resolveDraftProjectSelection,8 resolveDraftProjectSelection,
9 resolveEnvironmentProjectMatch,
9} from "./new-task-project-selection";10} from "./new-task-project-selection";
1011
11function makeProject(id: string, environmentId = "environment"): EnvironmentProject {12function makeProject(
13 id: string,
14 environmentId = "environment",
15 options: {
16 readonly title?: string;
17 readonly workspaceRoot?: string;
18 readonly repositoryKey?: string;
19 } = {},
20): EnvironmentProject {
12 return {21 return {
13 environmentId: EnvironmentId.make(environmentId),22 environmentId: EnvironmentId.make(environmentId),
14 id: ProjectId.make(id),23 id: ProjectId.make(id),
15 title: id,24 title: options.title ?? id,
16 workspaceRoot: `/work/${id}`,25 workspaceRoot: options.workspaceRoot ?? `/work/${id}`,
17 repositoryIdentity: null,26 repositoryIdentity: options.repositoryKey
27 ? {
28 canonicalKey: options.repositoryKey,
29 locator: {
30 source: "git-remote",
31 remoteName: "origin",
32 remoteUrl: `https://${options.repositoryKey}.git`,
33 },
34 }
35 : null,
18 defaultModelSelection: null,36 defaultModelSelection: null,
19 scripts: [],37 scripts: [],
20 createdAt: "2026-07-01T00:00:00.000Z",38 createdAt: "2026-07-01T00:00:00.000Z",
...@@ -51,6 +69,55 @@ describe("getProjectScopeSelectionTarget", () => {...@@ -51,6 +69,55 @@ describe("getProjectScopeSelectionTarget", () => {
51 });69 });
52});70});
5371
72describe("resolveEnvironmentProjectMatch", () => {
73 it("follows the same repository onto the target machine", () => {
74 const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" });
75 const target = [
76 makeProject("other", "server", { repositoryKey: "github.com/t3tools/other" }),
77 makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }),
78 ];
79 expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]);
80 });
81
82 it("falls back to workspace basename, then title, for unindexed projects", () => {
83 const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" });
84 const byBasename = [
85 makeProject("other", "server"),
86 makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }),
87 ];
88 expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]);
89
90 const byTitle = [
91 makeProject("other", "server"),
92 makeProject("srv", "server", { title: "t3code" }),
93 ];
94 expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]);
95 });
96
97 it("does not treat a known different repository as a basename or title match", () => {
98 const selected = makeProject("t3code", "mac", {
99 repositoryKey: "github.com/t3tools/t3code",
100 workspaceRoot: "/Users/me/t3code",
101 });
102 const fork = makeProject("fork", "server", {
103 repositoryKey: "github.com/someone/t3code",
104 title: "t3code",
105 workspaceRoot: "/home/me/t3code",
106 });
107 const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" });
108 expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed);
109 // Without any weaker match the fork is still the first-project fallback.
110 expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork);
111 });
112
113 it("falls back to the first project on the target so the draft has a key to carry over to", () => {
114 const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" });
115 const target = [makeProject("unrelated", "server"), makeProject("also-unrelated", "server")];
116 expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[0]);
117 expect(resolveEnvironmentProjectMatch([], selected)).toBeNull();
118 });
119});
120
54describe("resolveDraftProjectSelection", () => {121describe("resolveDraftProjectSelection", () => {
55 it("preserves an explicit project selection", () => {122 it("preserves an explicit project selection", () => {
56 const project = makeProject("t3code");123 const project = makeProject("t3code");
apps/mobile/src/features/threads/new-task-project-selection.ts+47
...@@ -26,6 +26,53 @@ function getOnlySelectableProject(...@@ -26,6 +26,53 @@ function getOnlySelectableProject(
26 return onlyScope?.representative ?? null;26 return onlyScope?.representative ?? null;
27}27}
2828
29/**
30 * Picks the project on a target environment that corresponds to the project
31 * currently selected in the new-task flow, so switching computers follows the
32 * same repo. Repository identity is preferred; projects without one (e.g. not
33 * yet indexed) fall back to workspace basename, then title. When nothing
34 * matches, the first project on the target stands in — the same fallback the
35 * render path applies when no key is selected — so the draft always has a
36 * concrete key to carry over to.
37 */
38export function resolveEnvironmentProjectMatch(
39 projectsOnTarget: ReadonlyArray<EnvironmentProject>,
40 selectedProject: EnvironmentProject | null,
41): EnvironmentProject | null {
42 const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;
43 // `|| null` (not `??`): a pending-task placeholder project can have an empty
44 // workspaceRoot, and an "" basename would match nothing meaningful.
45 const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
46 // The weaker signals only apply where identity is unknown on at least one
47 // side; two known, different repositories never match on a shared basename
48 // or title (mirrors the environment list filter in the new-task flow).
49 const isKnownMismatch = (project: EnvironmentProject) => {
50 const projectKey = project.repositoryIdentity?.canonicalKey ?? null;
51 return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey;
52 };
53 return (
54 (repositoryKey !== null
55 ? projectsOnTarget.find(
56 (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey,
57 )
58 : undefined) ??
59 (workspaceBasename !== null
60 ? projectsOnTarget.find(
61 (project) =>
62 !isKnownMismatch(project) &&
63 project.workspaceRoot.split("/").at(-1) === workspaceBasename,
64 )
65 : undefined) ??
66 (selectedProject !== null
67 ? projectsOnTarget.find(
68 (project) => !isKnownMismatch(project) && project.title === selectedProject.title,
69 )
70 : undefined) ??
71 projectsOnTarget[0] ??
72 null
73 );
74}
75
29export function resolveDraftProjectSelection(76export function resolveDraftProjectSelection(
30 selectedProjectKey: string | null,77 selectedProjectKey: string | null,
31 projects: ReadonlyArray<EnvironmentProject>,78 projects: ReadonlyArray<EnvironmentProject>,
apps/mobile/src/state/use-composer-drafts.test.ts+43
...@@ -145,6 +145,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({...@@ -145,6 +145,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({
145 loadIncomingShareDrafts: incomingShareStorageMocks.load,145 loadIncomingShareDrafts: incomingShareStorageMocks.load,
146}));146}));
147147
148import type { DraftComposerAttachment } from "../lib/composerImages";
148import { appAtomRegistry } from "./atom-registry";149import { appAtomRegistry } from "./atom-registry";
149import { threadOutboxManager } from "./thread-outbox";150import { threadOutboxManager } from "./thread-outbox";
150import {151import {
...@@ -1433,6 +1434,48 @@ describe("mobile composer drafts", () => {...@@ -1433,6 +1434,48 @@ describe("mobile composer drafts", () => {
1433 expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts);1434 expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts);
1434 });1435 });
14351436
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 = {
1441 id: "image-1",
1442 type: "image",
1443 name: "screen.png",
1444 mimeType: "image/png",
1445 sizeBytes: 1,
1446 previewUri: "file:///drafts/screen.png",
1447 fileUri: "file:///drafts/screen.png",
1448 uploadedAttachmentId: "upload-1",
1449 uploadEnvironmentId: EnvironmentId.make("environment-1"),
1450 };
1451 const uploadedOnTarget: DraftComposerAttachment = {
1452 ...uploadedElsewhere,
1453 id: "image-2",
1454 uploadedAttachmentId: "upload-2",
1455 uploadEnvironmentId: EnvironmentId.make("environment-2"),
1456 };
1457
1458 const next = copyComposerDraftContentState(
1459 { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } },
1460 sourceKey,
1461 targetKey,
1462 );
1463
1464 expect(next[targetKey]?.attachments).toEqual([
1465 {
1466 id: "image-1",
1467 type: "image",
1468 name: "screen.png",
1469 mimeType: "image/png",
1470 sizeBytes: 1,
1471 previewUri: "file:///drafts/screen.png",
1472 fileUri: "file:///drafts/screen.png",
1473 },
1474 uploadedOnTarget,
1475 ]);
1476 expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]);
1477 });
1478
1436 it("merges shared content into a project draft without duplicating retries", () => {1479 it("merges shared content into a project draft without duplicating retries", () => {
1437 const draftKey = "new-task:environment-1:project-1";1480 const draftKey = "new-task:environment-1:project-1";
1438 const sharedAttachment = {1481 const sharedAttachment = {
apps/mobile/src/state/use-composer-drafts.ts+19-1
...@@ -1049,17 +1049,35 @@ export function copyComposerDraftContentState(...@@ -1049,17 +1049,35 @@ export function copyComposerDraftContentState(
1049 if (!sourceHasContent || targetHasContent) {1049 if (!sourceHasContent || targetHasContent) {
1050 return current;1050 return current;
1051 }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 );
1052 return {1063 return {
1053 ...current,1064 ...current,
1054 [targetDraftKey]: {1065 [targetDraftKey]: {
1055 ...target,1066 ...target,
1056 text: source.text,1067 text: source.text,
1057 attachments: source.attachments,1068 attachments,
1058 ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}),1069 ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}),
1059 },1070 },
1060 };1071 };
1061}1072}
10621073
1074function stripAttachmentUploadReference(
1075 attachment: DraftComposerAttachment,
1076): DraftComposerAttachment {
1077 const { uploadedAttachmentId: _id, uploadEnvironmentId: _environmentId, ...rest } = attachment;
1078 return rest;
1079}
1080
1063export async function copyComposerDraftContentIfEmpty(1081export async function copyComposerDraftContentIfEmpty(
1064 sourceDraftKey: string,1082 sourceDraftKey: string,
1065 targetDraftKey: string,1083 targetDraftKey: string,