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 {
9292 resolveNewTaskBranchWorktreePath,
9393 resolveNewTaskLocalWorkspaceSelection,
9494} from "./new-task-context-presentation";
95import { resolveEnvironmentProjectMatch } from "./new-task-project-selection";
9596
9697type WorkspaceMode = "local" | "worktree";
9798
......@@ -622,51 +623,44 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
622623 );
623624 }, [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(
626630 (project: EnvironmentProject) => {
627 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
628 const nextDraftKey = `new-task:${nextProjectKey}`;
631 const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`;
629632 if (
630633 selectedProjectDraftKey?.startsWith("new-task:") &&
631634 selectedProjectDraftKey !== nextDraftKey
632635 ) {
633636 void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
634637 }
635 setSelectedEnvironmentId(project.environmentId);
636 setSelectedProjectKey(nextProjectKey);
637638 },
638639 [selectedProjectDraftKey],
639640 );
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
641651 const selectEnvironment = useCallback(
642652 (environmentId: EnvironmentId) => {
643 const projectsOnTarget = projects.filter(
644 (project) => project.environmentId === environmentId,
653 const match = resolveEnvironmentProjectMatch(
654 projects.filter((project) => project.environmentId === environmentId),
655 selectedProject,
645656 );
646 const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;
647 // Prefer the repository identity; projects without one (e.g. not yet
648 // indexed) fall back to workspace basename, then title, so switching
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);
657 if (match) {
658 carryDraftContentTo(match);
659 }
666660 setSelectedEnvironmentId(environmentId);
667661 setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null);
668662 },
669 [projects, selectedProject],
663 [projects, selectedProject, carryDraftContentTo],
670664 );
671665
672666 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";
66import {
77 getProjectScopeSelectionTarget,
88 resolveDraftProjectSelection,
9 resolveEnvironmentProjectMatch,
910} 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 {
1221 return {
1322 environmentId: EnvironmentId.make(environmentId),
1423 id: ProjectId.make(id),
15 title: id,
16 workspaceRoot: `/work/${id}`,
17 repositoryIdentity: null,
24 title: options.title ?? id,
25 workspaceRoot: options.workspaceRoot ?? `/work/${id}`,
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,
1836 defaultModelSelection: null,
1937 scripts: [],
2038 createdAt: "2026-07-01T00:00:00.000Z",
......@@ -51,6 +69,55 @@ describe("getProjectScopeSelectionTarget", () => {
5169 });
5270});
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
54121describe("resolveDraftProjectSelection", () => {
55122 it("preserves an explicit project selection", () => {
56123 const project = makeProject("t3code");
apps/mobile/src/features/threads/new-task-project-selection.ts+47
......@@ -26,6 +26,53 @@ function getOnlySelectableProject(
2626 return onlyScope?.representative ?? null;
2727}
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
2976export function resolveDraftProjectSelection(
3077 selectedProjectKey: string | null,
3178 projects: ReadonlyArray<EnvironmentProject>,
apps/mobile/src/state/use-composer-drafts.test.ts+43
......@@ -145,6 +145,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({
145145 loadIncomingShareDrafts: incomingShareStorageMocks.load,
146146}));
147147
148import type { DraftComposerAttachment } from "../lib/composerImages";
148149import { appAtomRegistry } from "./atom-registry";
149150import { threadOutboxManager } from "./thread-outbox";
150151import {
......@@ -1433,6 +1434,48 @@ describe("mobile composer drafts", () => {
14331434 expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts);
14341435 });
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
14361479 it("merges shared content into a project draft without duplicating retries", () => {
14371480 const draftKey = "new-task:environment-1:project-1";
14381481 const sharedAttachment = {
apps/mobile/src/state/use-composer-drafts.ts+19-1
......@@ -1049,17 +1049,35 @@ export function copyComposerDraftContentState(
10491049 if (!sourceHasContent || targetHasContent) {
10501050 return current;
10511051 }
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 );
10521063 return {
10531064 ...current,
10541065 [targetDraftKey]: {
10551066 ...target,
10561067 text: source.text,
1057 attachments: source.attachments,
1068 attachments,
10581069 ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}),
10591070 },
10601071 };
10611072}
10621073
1074function stripAttachmentUploadReference(
1075 attachment: DraftComposerAttachment,
1076): DraftComposerAttachment {
1077 const { uploadedAttachmentId: _id, uploadEnvironmentId: _environmentId, ...rest } = attachment;
1078 return rest;
1079}
1080
10631081export async function copyComposerDraftContentIfEmpty(
10641082 sourceDraftKey: string,
10651083 targetDraftKey: string,