authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-07 09:22:41+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 00:22:41-07:00
log7dda0b1c0a95603f1954b81a9fee699be92668f0
treeeb042a6439c8f7255d243ac1d8bbf06ce3381761
parente3b644c5af878ef5d9636153401dfd4d12ee6e81
signature Signed by PGP key B5690EEEBB952194

feat(mobile): start a new thread on an existing branch (#10359)


12 files changed, 471 insertions(+), 53 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+11
......@@ -214,6 +214,17 @@ export function HomeRouteScreen() {
214214 onSelectThread={handleSelectThread}
215215 onSelectPendingTask={openPendingTask}
216216 onDeletePendingTask={confirmDeletePendingTask}
217 onNewThreadOnBranch={(thread) => {
218 navigation.navigate("NewTaskSheet", {
219 screen: "NewTaskDraft",
220 params: {
221 environmentId: String(thread.environmentId),
222 projectId: String(thread.projectId),
223 branch: thread.branch,
224 worktreePath: thread.worktreePath,
225 },
226 });
227 }}
217228 onNewThreadInProject={(project) => {
218229 navigation.navigate("NewTaskSheet", {
219230 screen: "NewTaskDraft",
apps/mobile/src/features/home/HomeScreen.tsx+5
......@@ -124,6 +124,7 @@ interface HomeScreenProps {
124124 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
125125 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
126126 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
127 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
127128 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
128129}
129130
......@@ -824,6 +825,7 @@ export function HomeScreen(props: HomeScreenProps) {
824825 const movedId = `${thread.environmentId}:${thread.id}`;
825826 return (
826827 <ThreadListV2Row
828 onNewThreadOnBranch={props.onNewThreadOnBranch}
827829 thread={thread}
828830 variant={item.item.variant}
829831 hasQueuedMessages={queuedThreadKeys.has(movedId)}
......@@ -911,6 +913,7 @@ export function HomeScreen(props: HomeScreenProps) {
911913 props.onDeletePendingTask,
912914 props.onSelectPendingTask,
913915 props.onSelectThread,
916 props.onNewThreadOnBranch,
914917 props.savedConnectionsById,
915918 serverConfigs,
916919 shelfPreferencesLoaded,
......@@ -1001,6 +1004,7 @@ export function HomeScreen(props: HomeScreenProps) {
10011004 const thread = item.thread;
10021005 return (
10031006 <ThreadListRow
1007 onNewThreadOnBranch={props.onNewThreadOnBranch}
10041008 variant="compact"
10051009 thread={thread}
10061010 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
......@@ -1050,6 +1054,7 @@ export function HomeScreen(props: HomeScreenProps) {
10501054 props.onNewThreadInProject,
10511055 props.onSelectPendingTask,
10521056 props.onSelectThread,
1057 props.onNewThreadOnBranch,
10531058 props.searchQuery,
10541059 props.savedConnectionsById,
10551060 threadSearchMatchByKey,
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+16
......@@ -449,6 +449,21 @@ function AdaptiveWorkspaceLayoutContent(
449449 });
450450 }, [navigation]);
451451
452 const handleNewThreadOnBranch = useCallback(
453 (thread: EnvironmentThreadShell) => {
454 navigation.navigate("NewTaskSheet", {
455 screen: "NewTaskDraft",
456 params: {
457 environmentId: String(thread.environmentId),
458 projectId: String(thread.projectId),
459 branch: thread.branch,
460 worktreePath: thread.worktreePath,
461 },
462 });
463 },
464 [navigation],
465 );
466
452467 const handleNewThreadInProject = useCallback(
453468 (project: EnvironmentProject) => {
454469 navigation.navigate("NewTaskSheet", {
......@@ -543,6 +558,7 @@ function AdaptiveWorkspaceLayoutContent(
543558 onOpenSettings={handleOpenSettings}
544559 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
545560 onNewThreadInProject={handleNewThreadInProject}
561 onNewThreadOnBranch={handleNewThreadOnBranch}
546562 onSelectThread={handleSelectThread}
547563 onSearchQueryChange={setPrimarySidebarSearchQuery}
548564 searchQuery={primarySidebarSearchQuery}
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+16-30
......@@ -35,7 +35,7 @@ import {
3535 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
3636} from "../layout/native-mail-search-toolbar";
3737import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
38import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";
38import { checkoutNewTaskBranch } from "./checkout-new-task-branch";
3939
4040function SelectionRow(props: {
4141 readonly icon?: "arrow.triangle.branch" | ReactNode;
......@@ -257,43 +257,29 @@ export function NewTaskBranchPickerRouteScreen() {
257257 void Haptics.selectionAsync();
258258
259259 try {
260 let selectedBranch = branch;
261 const needsCheckout = shouldCheckoutNewTaskBranch({
262 branchIsCurrent: branch.current,
263 branchWorktreePath: branch.worktreePath,
260 if (!flow.selectedProject) return;
261 setSwitchingBranchName(branch.name);
262 const result = await checkoutNewTaskBranch({
263 branch,
264 project: flow.selectedProject,
264265 workspaceMode: flow.workspaceMode,
266 switchRef,
265267 });
266 if (needsCheckout && flow.selectedProject) {
267 setSwitchingBranchName(branch.name);
268 const result = await switchRef({
269 environmentId: flow.selectedProject.environmentId,
270 input: {
271 cwd: flow.selectedProject.workspaceRoot,
272 refName: branch.name,
273 },
274 });
275 if (result._tag === "Failure") {
276 if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) {
277 const error = squashAtomCommandFailure(result);
278 Alert.alert(
279 "Could not switch branch",
280 error instanceof Error ? error.message : "The branch could not be checked out.",
281 );
282 }
283 return;
268 if (result._tag === "Failure") {
269 if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) {
270 const error = squashAtomCommandFailure(result);
271 Alert.alert(
272 "Could not switch branch",
273 error instanceof Error ? error.message : "The branch could not be checked out.",
274 );
284275 }
285 selectedBranch = {
286 ...branch,
287 current: true,
288 isRemote: false,
289 name: result.value.refName ?? branch.name,
290 };
276 return;
291277 }
292278
293279 // The checkout has already changed the repository. Persist the matching
294280 // draft selection even if the native sheet was dismissed while the
295281 // command was in flight; only visible-screen work is focus-gated below.
296 flow.selectBranch(selectedBranch);
282 flow.selectBranch(result.value);
297283 if (!mountedRef.current || !navigation.isFocused()) {
298284 return;
299285 }
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+135-14
......@@ -1,5 +1,16 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { useMemo } from "react";
1import { useNavigation, usePreventRemove, type StaticScreenProps } from "@react-navigation/native";
2import { useEffect, useMemo, useRef, useState } from "react";
3import { Alert, View } from "react-native";
4import {
5 isAtomCommandInterrupted,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
8import { AppText as Text } from "../../components/AppText";
9import { useProjects } from "../../state/entities";
10import { useAtomCommand } from "../../state/use-atom-command";
11import { useWorkspaceState } from "../../state/workspace";
12import { vcsEnvironment } from "../../state/vcs";
13import { checkoutNewTaskBranch } from "./checkout-new-task-branch";
314import { NativeStackScreenOptions } from "../../native/StackHeader";
415
516import { NewTaskDraftScreen } from "./NewTaskDraftScreen";
......@@ -7,6 +18,8 @@ import { NewTaskDraftScreen } from "./NewTaskDraftScreen";
718type NewTaskDraftRouteParams = {
819 readonly environmentId?: string | string[];
920 readonly projectId?: string | string[];
21 readonly branch?: string | null;
22 readonly worktreePath?: string | null;
1023 readonly title?: string | string[];
1124 readonly pendingTaskId?: string | string[];
1225 readonly draftId?: string | string[];
......@@ -14,7 +27,15 @@ type NewTaskDraftRouteParams = {
1427};
1528
1629export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {
17 const params = route.params ?? {};
30 const params = useMemo(() => route.params ?? {}, [route.params]);
31 const pendingTaskId = Array.isArray(params.pendingTaskId)
32 ? params.pendingTaskId[0]
33 : params.pendingTaskId;
34 const draftId = Array.isArray(params.draftId) ? params.draftId[0] : params.draftId;
35 const projects = useProjects();
36 const { state: catalogState } = useWorkspaceState();
37 const navigation = useNavigation();
38 const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
1839
1940 // Keyed on the params object so a fresh navigation to this (already
2041 // mounted) screen produces a new reference, letting the draft screen
......@@ -25,10 +46,104 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
2546 ? params.environmentId[0]
2647 : params.environmentId,
2748 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
49 branch: params.branch,
50 worktreePath: params.worktreePath,
2851 }),
29 [route.params],
52 [params],
3053 );
3154
55 const [preparation, setPreparation] = useState<{
56 request: typeof initialProjectRef;
57 result: Awaited<ReturnType<typeof checkoutNewTaskBranch>>;
58 workspaceRoot: string | undefined;
59 } | null>(null);
60 const project = projects.find(
61 (candidate) =>
62 candidate.environmentId === initialProjectRef.environmentId &&
63 candidate.id === initialProjectRef.projectId,
64 );
65 const environmentId = project?.environmentId;
66 const workspaceRoot = project?.workspaceRoot;
67 const needsPreparation = Boolean(initialProjectRef.branch && !pendingTaskId && !draftId);
68
69 const [pendingCheckouts, setPendingCheckouts] = useState(0);
70 const checkoutTail = useRef(Promise.resolve());
71 const waitingForProject =
72 !project &&
73 (catalogState.isLoadingConnections ||
74 (!catalogState.hasLoadedShellSnapshot &&
75 catalogState.hasConnectingEnvironment &&
76 catalogState.connectionError === null));
77
78 useEffect(() => {
79 if (!needsPreparation || !initialProjectRef.branch || waitingForProject) return;
80 const branchName = initialProjectRef.branch;
81 let active = true;
82 setPendingCheckouts((count) => count + 1);
83 // Serialize replacements: ignoring a stale result cannot undo its Git mutation.
84 checkoutTail.current = checkoutTail.current.then(async () => {
85 if (!active) {
86 setPendingCheckouts((count) => count - 1);
87 return;
88 }
89 const result = await checkoutNewTaskBranch({
90 // A thread's branch is historical; only switchRef can establish that
91 // the shared project checkout now matches it.
92 branch: {
93 name: branchName,
94 current: false,
95 isDefault: false,
96 worktreePath: initialProjectRef.worktreePath ?? null,
97 },
98 project: environmentId && workspaceRoot ? { environmentId, workspaceRoot } : null,
99 workspaceMode: "local",
100 switchRef,
101 });
102 setPendingCheckouts((count) => count - 1);
103 if (active) setPreparation({ request: initialProjectRef, result, workspaceRoot });
104 });
105 return () => {
106 active = false;
107 };
108 }, [
109 environmentId,
110 workspaceRoot,
111 initialProjectRef,
112 needsPreparation,
113 switchRef,
114 waitingForProject,
115 ]);
116
117 const result =
118 preparation?.request === initialProjectRef && preparation.workspaceRoot === workspaceRoot
119 ? preparation.result
120 : null;
121 // The native-stack guard covers iOS swipe dismissal as well as back actions.
122 // A replaced request must settle too before the shared checkout is left behind.
123 const checkoutPending = pendingCheckouts > 0 || (needsPreparation && result === null);
124 usePreventRemove(checkoutPending, () => undefined);
125 useEffect(() => {
126 if (checkoutPending || result?._tag !== "Failure") return;
127 if (!isAtomCommandInterrupted(result)) {
128 const error = squashAtomCommandFailure(result);
129 Alert.alert(
130 "Could not switch branch",
131 error instanceof Error ? error.message : "The branch could not be checked out.",
132 );
133 }
134 navigation.goBack();
135 }, [checkoutPending, result, navigation]);
136
137 const preparedProjectRef = useMemo(
138 () =>
139 result?._tag === "Success"
140 ? { ...initialProjectRef, branch: result.value.name }
141 : initialProjectRef,
142 [initialProjectRef, result],
143 );
144 // Send/queue remain unavailable on failure while the unlocked route closes.
145 const preparingBranch = checkoutPending || (needsPreparation && result?._tag !== "Success");
146
32147 return (
33148 <>
34149 <NativeStackScreenOptions
......@@ -36,16 +151,22 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
36151 title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"),
37152 }}
38153 />
39 <NewTaskDraftScreen
40 initialProjectRef={initialProjectRef}
41 incomingShareId={
42 Array.isArray(params.incomingShareId) ? params.incomingShareId[0] : params.incomingShareId
43 }
44 pendingTaskId={
45 Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
46 }
47 draftId={Array.isArray(params.draftId) ? params.draftId[0] : params.draftId}
48 />
154 {preparingBranch ? (
155 <View className="flex-1 items-center justify-center bg-screen">
156 <Text className="text-foreground">Switching branch...</Text>
157 </View>
158 ) : (
159 <NewTaskDraftScreen
160 initialProjectRef={preparedProjectRef}
161 incomingShareId={
162 Array.isArray(params.incomingShareId)
163 ? params.incomingShareId[0]
164 : params.incomingShareId
165 }
166 pendingTaskId={pendingTaskId}
167 draftId={draftId}
168 />
169 )}
49170 </>
50171 );
51172}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+24
......@@ -76,6 +76,7 @@ import {
7676 getComposerDraftSnapshot,
7777 mergeComposerDraftContent,
7878 restoreComposerDraftSnapshot,
79 updateComposerDraftSettings,
7980 scheduleUnusedComposerAttachmentCleanup,
8081 type ComposerDraft,
8182 waitForComposerDraftsLoaded,
......@@ -140,6 +141,8 @@ export function NewTaskDraftScreen(props: {
140141 readonly initialProjectRef?: {
141142 readonly environmentId?: string;
142143 readonly projectId?: string;
144 readonly branch?: string | null;
145 readonly worktreePath?: string | null;
143146 };
144147 /** Queued outbox message id when editing an existing pending task. */
145148 readonly pendingTaskId?: string;
......@@ -526,6 +529,26 @@ export function NewTaskDraftScreen(props: {
526529 if (appliedInitialProjectKeyRef.current === directProjectKey) {
527530 return;
528531 }
532 if (props.initialProjectRef?.branch) {
533 if (
534 selectedProject?.environmentId !== directProject.environmentId ||
535 selectedProject.id !== directProject.id
536 ) {
537 setProject(directProject);
538 return;
539 }
540 if (!flow.draftKey) return;
541 // The route completes checkout before mounting this composer. Local
542 // mode reuses an existing worktree; worktree mode would create another.
543 updateComposerDraftSettings(flow.draftKey, {
544 workspaceSelection: {
545 mode: "local",
546 branch: props.initialProjectRef.branch,
547 worktreePath: props.initialProjectRef.worktreePath ?? null,
548 startFromOrigin: false,
549 },
550 });
551 }
529552 appliedInitialProjectKeyRef.current = directProjectKey;
530553 if (
531554 selectedProject?.environmentId === directProject.environmentId &&
......@@ -559,6 +582,7 @@ export function NewTaskDraftScreen(props: {
559582 }, [
560583 projectScopes,
561584 projects,
585 flow.draftKey,
562586 props.initialProjectRef,
563587 props.incomingShareId,
564588 props.pendingTaskId,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+4
......@@ -103,6 +103,7 @@ interface ThreadNavigationSidebarProps {
103103 readonly selectedThreadKey: string | null;
104104 readonly onOpenSettings: () => void;
105105 readonly onOpenEnvironmentSettings: () => void;
106 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
106107 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
107108 readonly onSearchQueryChange: (query: string) => void;
108109 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
......@@ -890,6 +891,7 @@ function ThreadNavigationSidebarPane(
890891 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
891892 return (
892893 <ThreadListV2Row
894 onNewThreadOnBranch={props.onNewThreadOnBranch}
893895 thread={thread}
894896 variant={item.item.variant}
895897 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
......@@ -1024,6 +1026,7 @@ function ThreadNavigationSidebarPane(
10241026 const thread = item.thread;
10251027 return (
10261028 <ThreadListRow
1029 onNewThreadOnBranch={props.onNewThreadOnBranch}
10271030 variant="sidebar"
10281031 thread={thread}
10291032 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
......@@ -1087,6 +1090,7 @@ function ThreadNavigationSidebarPane(
10871090 projectTitleByProjectKey,
10881091 regenerateThreadTitle,
10891092 props.onNewThreadInProject,
1093 props.onNewThreadOnBranch,
10901094 props.searchQuery,
10911095 props.selectedThreadKey,
10921096 props.width,
apps/mobile/src/features/threads/checkout-new-task-branch.test.ts created+132
......@@ -0,0 +1,132 @@
1import * as NodeChildProcess from "node:child_process";
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as NodeUtil from "node:util";
6import { EnvironmentId } from "@t3tools/contracts";
7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
9
10import { checkoutNewTaskBranch } from "./checkout-new-task-branch";
11
12const exec = NodeUtil.promisify(NodeChildProcess.execFile);
13let directory: string;
14let cwd: string;
15const git = (...args: string[]) => exec("git", ["-C", cwd, ...args]);
16const branch = { name: "feature/a", current: false, isDefault: false, worktreePath: null };
17const environmentId = EnvironmentId.make("branch-test-environment");
18
19beforeEach(async () => {
20 directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-branch-selection-"));
21 cwd = NodePath.join(directory, "project");
22 await exec("git", ["init", "-b", "main", cwd]);
23 await git("config", "user.name", "Branch test");
24 await git("config", "user.email", "branch-test@example.com");
25 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "main\n");
26 await git("add", ".");
27 await git("commit", "-m", "main");
28 await git("checkout", "-b", branch.name);
29 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "feature\n");
30 await git("commit", "-am", "feature");
31 await git("checkout", "main");
32});
33
34afterEach(async () => {
35 await NodeFSP.rm(directory, { recursive: true, force: true });
36});
37
38function selectBranch(switchRef: Parameters<typeof checkoutNewTaskBranch>[0]["switchRef"]) {
39 return checkoutNewTaskBranch({
40 branch,
41 project: { environmentId, workspaceRoot: cwd },
42 workspaceMode: "local",
43 switchRef,
44 });
45}
46
47const switchRef: Parameters<typeof checkoutNewTaskBranch>[0]["switchRef"] = (request) =>
48 settlePromise(async () => {
49 expect(request.environmentId).toBe(environmentId);
50 await exec("git", ["-C", request.input.cwd, "checkout", request.input.refName]);
51 const { stdout } = await exec("git", ["-C", request.input.cwd, "branch", "--show-current"]);
52 return { refName: stdout.trim() };
53 });
54
55describe("new-task branch checkout", () => {
56 it("switches main to the older thread's feature branch before returning a selection", async () => {
57 const result = await selectBranch(switchRef);
58 expect(result._tag).toBe("Success");
59 if (result._tag !== "Success") throw new Error("Checkout failed");
60 expect(result.value.name).toBe("feature/a");
61 expect(result.value.current).toBe(true);
62 expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a");
63 expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe("feature\n");
64 });
65
66 it("does not release the selection while checkout is still pending", async () => {
67 const entered = Promise.withResolvers<void>();
68 const release = Promise.withResolvers<void>();
69 let completed = false;
70 const selection = selectBranch(async (request) => {
71 entered.resolve();
72 await release.promise;
73 return switchRef(request);
74 }).then((result) => {
75 completed = true;
76 return result;
77 });
78 await entered.promise;
79 expect(completed).toBe(false);
80 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
81 release.resolve();
82 expect((await selection)._tag).toBe("Success");
83 expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a");
84 });
85
86 it("returns checkout failure without selecting the branch or losing dirty files", async () => {
87 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "unsaved local changes\n");
88 const result = await selectBranch(switchRef);
89 expect(result._tag).toBe("Failure");
90 if (result._tag !== "Failure") throw new Error("Expected checkout to fail");
91 expect(String(squashAtomCommandFailure(result))).toContain("would be overwritten");
92 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
93 expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe(
94 "unsaved local changes\n",
95 );
96 });
97
98 it("fails when the source project is unavailable instead of releasing a composer selection", async () => {
99 const result = await checkoutNewTaskBranch({
100 branch,
101 project: null,
102 workspaceMode: "local",
103 switchRef: () => {
104 throw new Error("An unavailable project must not run checkout");
105 },
106 });
107 expect(result._tag).toBe("Failure");
108 if (result._tag !== "Failure") throw new Error("Expected an unavailable-project failure");
109 expect(String(squashAtomCommandFailure(result))).toContain("selected project is unavailable");
110 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
111 });
112
113 it("reuses an existing worktree without switching the project checkout", async () => {
114 const worktreePath = NodePath.join(directory, "worktree");
115 await git("worktree", "add", worktreePath, "feature/a");
116 const result = await checkoutNewTaskBranch({
117 branch: { ...branch, worktreePath },
118 project: { environmentId, workspaceRoot: cwd },
119 workspaceMode: "local",
120 switchRef: () => {
121 throw new Error("Existing worktrees must not switch the project checkout");
122 },
123 });
124 expect(result._tag).toBe("Success");
125 if (result._tag !== "Success") throw new Error("Worktree selection failed");
126 expect(result.value.worktreePath).toBe(worktreePath);
127 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
128 expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe(
129 "feature\n",
130 );
131 });
132});
apps/mobile/src/features/threads/checkout-new-task-branch.ts created+49
......@@ -0,0 +1,49 @@
1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
3import {
4 type AtomCommandResult,
5 mapAtomCommandResult,
6} from "@t3tools/client-runtime/state/runtime";
7import type { VcsSwitchRefInput, VcsSwitchRefResult } from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import { AsyncResult } from "effect/unstable/reactivity";
10
11import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";
12
13/** Resolve a composer branch only after its checkout succeeds. Existing worktrees
14 * and new-worktree base selections already identify a separate workspace. */
15export async function checkoutNewTaskBranch<E>(input: {
16 readonly branch: VcsRef;
17 readonly project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot"> | null;
18 readonly workspaceMode: "local" | "worktree";
19 readonly switchRef: (request: {
20 readonly environmentId: EnvironmentProject["environmentId"];
21 readonly input: VcsSwitchRefInput;
22 }) => Promise<AtomCommandResult<VcsSwitchRefResult, E>>;
23}): Promise<AtomCommandResult<VcsRef, E | Error>> {
24 if (!input.project) {
25 return AsyncResult.failure(
26 Cause.fail(new Error("The selected project is unavailable. Reconnect and try again.")),
27 );
28 }
29 if (
30 !shouldCheckoutNewTaskBranch({
31 branchIsCurrent: input.branch.current,
32 branchWorktreePath: input.branch.worktreePath,
33 workspaceMode: input.workspaceMode,
34 })
35 ) {
36 return AsyncResult.success(input.branch);
37 }
38
39 const result = await input.switchRef({
40 environmentId: input.project.environmentId,
41 input: { cwd: input.project.workspaceRoot, refName: input.branch.name },
42 });
43 return mapAtomCommandResult(result, (value) => ({
44 ...input.branch,
45 current: true,
46 isRemote: false,
47 name: value.refName ?? input.branch.name,
48 }));
49}
apps/mobile/src/features/threads/thread-list-items.tsx+23-5
......@@ -8,7 +8,7 @@ import type { EnvironmentMachineKind } from "@t3tools/contracts";
88import type { MenuAction } from "@react-native-menu/menu";
99import { SymbolView } from "../../components/AppSymbol";
1010import { memo, useCallback, useMemo, type ComponentProps } from "react";
11import { Pressable, useWindowDimensions, View } from "react-native";
11import { Platform, Pressable, useWindowDimensions, View } from "react-native";
1212import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
1313import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1414import Svg, { Circle, Path } from "react-native-svg";
......@@ -453,6 +453,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
453453 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
454454 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
455455 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
456 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
456457 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
457458 readonly titleRegenerationSupported: boolean;
458459 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
......@@ -476,8 +477,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
476477 const selectedBackgroundColor = theme["--color-user-bubble"];
477478 const selectedForegroundColor = theme["--color-user-bubble-foreground"];
478479
479 const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
480 props;
480 const {
481 thread,
482 onSelectThread,
483 onArchiveThread,
484 onDeleteThread,
485 onRegenerateThreadTitle,
486 onNewThreadOnBranch,
487 } = props;
481488 const status = resolveThreadStatus(thread);
482489 const pr = useThreadPr(thread);
483490 const timestamp = relativeTime(
......@@ -515,6 +522,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
515522 );
516523 const menuActions = useMemo<MenuAction[]>(
517524 () => [
525 ...(thread.branch
526 ? [
527 {
528 id: "new-thread-on-branch",
529 title:
530 Platform.OS === "ios" ? "New thread on branch" : `New thread on ${thread.branch}`,
531 image: "square.and.pencil",
532 },
533 ]
534 : []),
518535 THREAD_ROW_MENU_ACTIONS[0]!,
519536 ...buildThreadTitleRegenerationMenuItems({
520537 supported: props.titleRegenerationSupported,
......@@ -522,7 +539,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
522539 }),
523540 THREAD_ROW_MENU_ACTIONS[1]!,
524541 ],
525 [props.titleRegenerationSupported, thread.titleRegeneration],
542 [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration],
526543 );
527544 const primaryAction = useMemo(
528545 () => ({
......@@ -535,11 +552,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
535552 );
536553 const handleMenuAction = useCallback(
537554 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
555 if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
538556 if (nativeEvent.event === "archive") handleArchive();
539557 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
540558 if (nativeEvent.event === "delete") handleDelete();
541559 },
542 [handleArchive, handleDelete, handleRegenerateTitle],
560 [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread],
543561 );
544562
545563 const statusPill = effectiveStatus ? (
apps/mobile/src/features/threads/thread-list-v2-items.tsx+21-4
......@@ -373,6 +373,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
373373 readonly fullSwipeWidth?: number;
374374 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
375375 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
376 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
376377 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
377378 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
378379 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
......@@ -412,6 +413,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
412413 onSelectThread,
413414 onDeleteThread,
414415 onRegenerateThreadTitle,
416 onNewThreadOnBranch,
415417 onSettleThread,
416418 onSnoozeThread,
417419 onUnsnoozeThread,
......@@ -589,6 +591,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
589591 );
590592 const handleMenuAction = useCallback(
591593 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
594 if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
592595 if (nativeEvent.event === "settle") handleSettle();
593596 if (nativeEvent.event === "unsettle") handleUnsettle();
594597 if (nativeEvent.event === "unsnooze") handleUnsnooze();
......@@ -611,6 +614,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
611614 }
612615 },
613616 [
617 onNewThreadOnBranch,
618 thread,
614619 handleArchive,
615620 handleDelete,
616621 handleRegenerateTitle,
......@@ -979,8 +984,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
979984 >
980985 {(close) => (
981986 <ControlPillMenu
982 actions={
983 snoozedRow
987 actions={[
988 ...(thread.branch
989 ? [
990 {
991 id: "new-thread-on-branch",
992 title:
993 Platform.OS === "ios"
994 ? "New thread on branch"
995 : `New thread on ${thread.branch}`,
996 image: "square.and.pencil",
997 },
998 ]
999 : []),
1000 ...(snoozedRow
9841001 ? snoozedMenuActions
9851002 : !props.settlementSupported
9861003 ? legacyMenuActions
......@@ -988,8 +1005,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
9881005 ? slimMenuActions
9891006 : swipeActions.secondary === "snooze"
9901007 ? snoozableCardMenuActions
991 : cardMenuActions
992 }
1008 : cardMenuActions),
1009 ]}
9931010 onPressAction={handleMenuAction}
9941011 shouldOpenOnLongPress
9951012 >
apps/mobile/src/lib/projectThreadStartTurn.test.ts+35
......@@ -66,3 +66,38 @@ describe("project thread title", () => {
6666 expect(input.message.text).toBe(text);
6767 });
6868});
69
70describe("new thread on an existing branch", () => {
71 it.each([null, "/worktrees/existing"])(
72 "reuses the selected workspace %s without preparing a new worktree",
73 (worktreePath) => {
74 const input = buildProjectThreadStartTurnInput({
75 projectId: ProjectId.make("project"),
76 projectCwd: "/workspace",
77 threadId: "new-thread",
78 commandId: "command",
79 messageId: "message",
80 createdAt: "2026-09-06T00:00:00Z",
81 text: "Start fresh",
82 uploadedAttachments: [],
83 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
84 runtimeMode: "full-access",
85 interactionMode: "default",
86 workspaceMode: "local",
87 branch: "feature/existing",
88 worktreePath,
89 startFromOrigin: false,
90 worktreeBranchName: "unused",
91 });
92
93 expect(input.bootstrap.createThread).toMatchObject({
94 projectId: "project",
95 branch: "feature/existing",
96 worktreePath,
97 });
98 expect(input.bootstrap).not.toHaveProperty("prepareWorktree");
99 expect(input.bootstrap).not.toHaveProperty("runSetupScript");
100 expect(input.threadId).toBe("new-thread");
101 },
102 );
103});