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() {...@@ -214,6 +214,17 @@ export function HomeRouteScreen() {
214 onSelectThread={handleSelectThread}214 onSelectThread={handleSelectThread}
215 onSelectPendingTask={openPendingTask}215 onSelectPendingTask={openPendingTask}
216 onDeletePendingTask={confirmDeletePendingTask}216 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 }}
217 onNewThreadInProject={(project) => {228 onNewThreadInProject={(project) => {
218 navigation.navigate("NewTaskSheet", {229 navigation.navigate("NewTaskSheet", {
219 screen: "NewTaskDraft",230 screen: "NewTaskDraft",
apps/mobile/src/features/home/HomeScreen.tsx+5
...@@ -124,6 +124,7 @@ interface HomeScreenProps {...@@ -124,6 +124,7 @@ interface HomeScreenProps {
124 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;124 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
125 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;125 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
126 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;126 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
127 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
127 readonly onNewThreadInProject: (project: EnvironmentProject) => void;128 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
128}129}
129130
...@@ -824,6 +825,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -824,6 +825,7 @@ export function HomeScreen(props: HomeScreenProps) {
824 const movedId = `${thread.environmentId}:${thread.id}`;825 const movedId = `${thread.environmentId}:${thread.id}`;
825 return (826 return (
826 <ThreadListV2Row827 <ThreadListV2Row
828 onNewThreadOnBranch={props.onNewThreadOnBranch}
827 thread={thread}829 thread={thread}
828 variant={item.item.variant}830 variant={item.item.variant}
829 hasQueuedMessages={queuedThreadKeys.has(movedId)}831 hasQueuedMessages={queuedThreadKeys.has(movedId)}
...@@ -911,6 +913,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -911,6 +913,7 @@ export function HomeScreen(props: HomeScreenProps) {
911 props.onDeletePendingTask,913 props.onDeletePendingTask,
912 props.onSelectPendingTask,914 props.onSelectPendingTask,
913 props.onSelectThread,915 props.onSelectThread,
916 props.onNewThreadOnBranch,
914 props.savedConnectionsById,917 props.savedConnectionsById,
915 serverConfigs,918 serverConfigs,
916 shelfPreferencesLoaded,919 shelfPreferencesLoaded,
...@@ -1001,6 +1004,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1001,6 +1004,7 @@ export function HomeScreen(props: HomeScreenProps) {
1001 const thread = item.thread;1004 const thread = item.thread;
1002 return (1005 return (
1003 <ThreadListRow1006 <ThreadListRow
1007 onNewThreadOnBranch={props.onNewThreadOnBranch}
1004 variant="compact"1008 variant="compact"
1005 thread={thread}1009 thread={thread}
1006 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}1010 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
...@@ -1050,6 +1054,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1050,6 +1054,7 @@ export function HomeScreen(props: HomeScreenProps) {
1050 props.onNewThreadInProject,1054 props.onNewThreadInProject,
1051 props.onSelectPendingTask,1055 props.onSelectPendingTask,
1052 props.onSelectThread,1056 props.onSelectThread,
1057 props.onNewThreadOnBranch,
1053 props.searchQuery,1058 props.searchQuery,
1054 props.savedConnectionsById,1059 props.savedConnectionsById,
1055 threadSearchMatchByKey,1060 threadSearchMatchByKey,
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+16
...@@ -449,6 +449,21 @@ function AdaptiveWorkspaceLayoutContent(...@@ -449,6 +449,21 @@ function AdaptiveWorkspaceLayoutContent(
449 });449 });
450 }, [navigation]);450 }, [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
452 const handleNewThreadInProject = useCallback(467 const handleNewThreadInProject = useCallback(
453 (project: EnvironmentProject) => {468 (project: EnvironmentProject) => {
454 navigation.navigate("NewTaskSheet", {469 navigation.navigate("NewTaskSheet", {
...@@ -543,6 +558,7 @@ function AdaptiveWorkspaceLayoutContent(...@@ -543,6 +558,7 @@ function AdaptiveWorkspaceLayoutContent(
543 onOpenSettings={handleOpenSettings}558 onOpenSettings={handleOpenSettings}
544 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}559 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
545 onNewThreadInProject={handleNewThreadInProject}560 onNewThreadInProject={handleNewThreadInProject}
561 onNewThreadOnBranch={handleNewThreadOnBranch}
546 onSelectThread={handleSelectThread}562 onSelectThread={handleSelectThread}
547 onSearchQueryChange={setPrimarySidebarSearchQuery}563 onSearchQueryChange={setPrimarySidebarSearchQuery}
548 searchQuery={primarySidebarSearchQuery}564 searchQuery={primarySidebarSearchQuery}
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+16-30
...@@ -35,7 +35,7 @@ import {...@@ -35,7 +35,7 @@ import {
35 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,35 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
36} from "../layout/native-mail-search-toolbar";36} from "../layout/native-mail-search-toolbar";
37import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";37import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
38import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";38import { checkoutNewTaskBranch } from "./checkout-new-task-branch";
3939
40function SelectionRow(props: {40function SelectionRow(props: {
41 readonly icon?: "arrow.triangle.branch" | ReactNode;41 readonly icon?: "arrow.triangle.branch" | ReactNode;
...@@ -257,43 +257,29 @@ export function NewTaskBranchPickerRouteScreen() {...@@ -257,43 +257,29 @@ export function NewTaskBranchPickerRouteScreen() {
257 void Haptics.selectionAsync();257 void Haptics.selectionAsync();
258258
259 try {259 try {
260 let selectedBranch = branch;260 if (!flow.selectedProject) return;
261 const needsCheckout = shouldCheckoutNewTaskBranch({261 setSwitchingBranchName(branch.name);
262 branchIsCurrent: branch.current,262 const result = await checkoutNewTaskBranch({
263 branchWorktreePath: branch.worktreePath,263 branch,
264 project: flow.selectedProject,
264 workspaceMode: flow.workspaceMode,265 workspaceMode: flow.workspaceMode,
266 switchRef,
265 });267 });
266 if (needsCheckout && flow.selectedProject) {268 if (result._tag === "Failure") {
267 setSwitchingBranchName(branch.name);269 if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) {
268 const result = await switchRef({270 const error = squashAtomCommandFailure(result);
269 environmentId: flow.selectedProject.environmentId,271 Alert.alert(
270 input: {272 "Could not switch branch",
271 cwd: flow.selectedProject.workspaceRoot,273 error instanceof Error ? error.message : "The branch could not be checked out.",
272 refName: branch.name,274 );
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;
284 }275 }
285 selectedBranch = {276 return;
286 ...branch,
287 current: true,
288 isRemote: false,
289 name: result.value.refName ?? branch.name,
290 };
291 }277 }
292278
293 // The checkout has already changed the repository. Persist the matching279 // The checkout has already changed the repository. Persist the matching
294 // draft selection even if the native sheet was dismissed while the280 // draft selection even if the native sheet was dismissed while the
295 // command was in flight; only visible-screen work is focus-gated below.281 // command was in flight; only visible-screen work is focus-gated below.
296 flow.selectBranch(selectedBranch);282 flow.selectBranch(result.value);
297 if (!mountedRef.current || !navigation.isFocused()) {283 if (!mountedRef.current || !navigation.isFocused()) {
298 return;284 return;
299 }285 }
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+135-14
...@@ -1,5 +1,16 @@...@@ -1,5 +1,16 @@
1import type { StaticScreenProps } from "@react-navigation/native";1import { useNavigation, usePreventRemove, type StaticScreenProps } from "@react-navigation/native";
2import { useMemo } from "react";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";
3import { NativeStackScreenOptions } from "../../native/StackHeader";14import { NativeStackScreenOptions } from "../../native/StackHeader";
415
5import { NewTaskDraftScreen } from "./NewTaskDraftScreen";16import { NewTaskDraftScreen } from "./NewTaskDraftScreen";
...@@ -7,6 +18,8 @@ import { NewTaskDraftScreen } from "./NewTaskDraftScreen";...@@ -7,6 +18,8 @@ import { NewTaskDraftScreen } from "./NewTaskDraftScreen";
7type NewTaskDraftRouteParams = {18type NewTaskDraftRouteParams = {
8 readonly environmentId?: string | string[];19 readonly environmentId?: string | string[];
9 readonly projectId?: string | string[];20 readonly projectId?: string | string[];
21 readonly branch?: string | null;
22 readonly worktreePath?: string | null;
10 readonly title?: string | string[];23 readonly title?: string | string[];
11 readonly pendingTaskId?: string | string[];24 readonly pendingTaskId?: string | string[];
12 readonly draftId?: string | string[];25 readonly draftId?: string | string[];
...@@ -14,7 +27,15 @@ type NewTaskDraftRouteParams = {...@@ -14,7 +27,15 @@ type NewTaskDraftRouteParams = {
14};27};
1528
16export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {29export 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
19 // Keyed on the params object so a fresh navigation to this (already40 // Keyed on the params object so a fresh navigation to this (already
20 // mounted) screen produces a new reference, letting the draft screen41 // mounted) screen produces a new reference, letting the draft screen
...@@ -25,10 +46,104 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf...@@ -25,10 +46,104 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
25 ? params.environmentId[0]46 ? params.environmentId[0]
26 : params.environmentId,47 : params.environmentId,
27 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,48 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
49 branch: params.branch,
50 worktreePath: params.worktreePath,
28 }),51 }),
29 [route.params],52 [params],
30 );53 );
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
32 return (147 return (
33 <>148 <>
34 <NativeStackScreenOptions149 <NativeStackScreenOptions
...@@ -36,16 +151,22 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf...@@ -36,16 +151,22 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
36 title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"),151 title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"),
37 }}152 }}
38 />153 />
39 <NewTaskDraftScreen154 {preparingBranch ? (
40 initialProjectRef={initialProjectRef}155 <View className="flex-1 items-center justify-center bg-screen">
41 incomingShareId={156 <Text className="text-foreground">Switching branch...</Text>
42 Array.isArray(params.incomingShareId) ? params.incomingShareId[0] : params.incomingShareId157 </View>
43 }158 ) : (
44 pendingTaskId={159 <NewTaskDraftScreen
45 Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId160 initialProjectRef={preparedProjectRef}
46 }161 incomingShareId={
47 draftId={Array.isArray(params.draftId) ? params.draftId[0] : params.draftId}162 Array.isArray(params.incomingShareId)
48 />163 ? params.incomingShareId[0]
164 : params.incomingShareId
165 }
166 pendingTaskId={pendingTaskId}
167 draftId={draftId}
168 />
169 )}
49 </>170 </>
50 );171 );
51}172}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+24
...@@ -76,6 +76,7 @@ import {...@@ -76,6 +76,7 @@ import {
76 getComposerDraftSnapshot,76 getComposerDraftSnapshot,
77 mergeComposerDraftContent,77 mergeComposerDraftContent,
78 restoreComposerDraftSnapshot,78 restoreComposerDraftSnapshot,
79 updateComposerDraftSettings,
79 scheduleUnusedComposerAttachmentCleanup,80 scheduleUnusedComposerAttachmentCleanup,
80 type ComposerDraft,81 type ComposerDraft,
81 waitForComposerDraftsLoaded,82 waitForComposerDraftsLoaded,
...@@ -140,6 +141,8 @@ export function NewTaskDraftScreen(props: {...@@ -140,6 +141,8 @@ export function NewTaskDraftScreen(props: {
140 readonly initialProjectRef?: {141 readonly initialProjectRef?: {
141 readonly environmentId?: string;142 readonly environmentId?: string;
142 readonly projectId?: string;143 readonly projectId?: string;
144 readonly branch?: string | null;
145 readonly worktreePath?: string | null;
143 };146 };
144 /** Queued outbox message id when editing an existing pending task. */147 /** Queued outbox message id when editing an existing pending task. */
145 readonly pendingTaskId?: string;148 readonly pendingTaskId?: string;
...@@ -526,6 +529,26 @@ export function NewTaskDraftScreen(props: {...@@ -526,6 +529,26 @@ export function NewTaskDraftScreen(props: {
526 if (appliedInitialProjectKeyRef.current === directProjectKey) {529 if (appliedInitialProjectKeyRef.current === directProjectKey) {
527 return;530 return;
528 }531 }
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 }
529 appliedInitialProjectKeyRef.current = directProjectKey;552 appliedInitialProjectKeyRef.current = directProjectKey;
530 if (553 if (
531 selectedProject?.environmentId === directProject.environmentId &&554 selectedProject?.environmentId === directProject.environmentId &&
...@@ -559,6 +582,7 @@ export function NewTaskDraftScreen(props: {...@@ -559,6 +582,7 @@ export function NewTaskDraftScreen(props: {
559 }, [582 }, [
560 projectScopes,583 projectScopes,
561 projects,584 projects,
585 flow.draftKey,
562 props.initialProjectRef,586 props.initialProjectRef,
563 props.incomingShareId,587 props.incomingShareId,
564 props.pendingTaskId,588 props.pendingTaskId,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+4
...@@ -103,6 +103,7 @@ interface ThreadNavigationSidebarProps {...@@ -103,6 +103,7 @@ interface ThreadNavigationSidebarProps {
103 readonly selectedThreadKey: string | null;103 readonly selectedThreadKey: string | null;
104 readonly onOpenSettings: () => void;104 readonly onOpenSettings: () => void;
105 readonly onOpenEnvironmentSettings: () => void;105 readonly onOpenEnvironmentSettings: () => void;
106 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
106 readonly onNewThreadInProject: (project: EnvironmentProject) => void;107 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
107 readonly onSearchQueryChange: (query: string) => void;108 readonly onSearchQueryChange: (query: string) => void;
108 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;109 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
...@@ -890,6 +891,7 @@ function ThreadNavigationSidebarPane(...@@ -890,6 +891,7 @@ function ThreadNavigationSidebarPane(
890 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);891 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
891 return (892 return (
892 <ThreadListV2Row893 <ThreadListV2Row
894 onNewThreadOnBranch={props.onNewThreadOnBranch}
893 thread={thread}895 thread={thread}
894 variant={item.item.variant}896 variant={item.item.variant}
895 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}897 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
...@@ -1024,6 +1026,7 @@ function ThreadNavigationSidebarPane(...@@ -1024,6 +1026,7 @@ function ThreadNavigationSidebarPane(
1024 const thread = item.thread;1026 const thread = item.thread;
1025 return (1027 return (
1026 <ThreadListRow1028 <ThreadListRow
1029 onNewThreadOnBranch={props.onNewThreadOnBranch}
1027 variant="sidebar"1030 variant="sidebar"
1028 thread={thread}1031 thread={thread}
1029 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}1032 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
...@@ -1087,6 +1090,7 @@ function ThreadNavigationSidebarPane(...@@ -1087,6 +1090,7 @@ function ThreadNavigationSidebarPane(
1087 projectTitleByProjectKey,1090 projectTitleByProjectKey,
1088 regenerateThreadTitle,1091 regenerateThreadTitle,
1089 props.onNewThreadInProject,1092 props.onNewThreadInProject,
1093 props.onNewThreadOnBranch,
1090 props.searchQuery,1094 props.searchQuery,
1091 props.selectedThreadKey,1095 props.selectedThreadKey,
1092 props.width,1096 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";...@@ -8,7 +8,7 @@ import type { EnvironmentMachineKind } from "@t3tools/contracts";
8import type { MenuAction } from "@react-native-menu/menu";8import type { MenuAction } from "@react-native-menu/menu";
9import { SymbolView } from "../../components/AppSymbol";9import { SymbolView } from "../../components/AppSymbol";
10import { memo, useCallback, useMemo, type ComponentProps } from "react";10import { memo, useCallback, useMemo, type ComponentProps } from "react";
11import { Pressable, useWindowDimensions, View } from "react-native";11import { Platform, Pressable, useWindowDimensions, View } from "react-native";
12import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";12import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
13import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";13import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
14import Svg, { Circle, Path } from "react-native-svg";14import Svg, { Circle, Path } from "react-native-svg";
...@@ -453,6 +453,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -453,6 +453,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
453 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;453 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
454 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;454 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
455 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;455 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
456 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
456 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;457 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
457 readonly titleRegenerationSupported: boolean;458 readonly titleRegenerationSupported: boolean;
458 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;459 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
...@@ -476,8 +477,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -476,8 +477,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
476 const selectedBackgroundColor = theme["--color-user-bubble"];477 const selectedBackgroundColor = theme["--color-user-bubble"];
477 const selectedForegroundColor = theme["--color-user-bubble-foreground"];478 const selectedForegroundColor = theme["--color-user-bubble-foreground"];
478479
479 const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =480 const {
480 props;481 thread,
482 onSelectThread,
483 onArchiveThread,
484 onDeleteThread,
485 onRegenerateThreadTitle,
486 onNewThreadOnBranch,
487 } = props;
481 const status = resolveThreadStatus(thread);488 const status = resolveThreadStatus(thread);
482 const pr = useThreadPr(thread);489 const pr = useThreadPr(thread);
483 const timestamp = relativeTime(490 const timestamp = relativeTime(
...@@ -515,6 +522,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -515,6 +522,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
515 );522 );
516 const menuActions = useMemo<MenuAction[]>(523 const menuActions = useMemo<MenuAction[]>(
517 () => [524 () => [
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 : []),
518 THREAD_ROW_MENU_ACTIONS[0]!,535 THREAD_ROW_MENU_ACTIONS[0]!,
519 ...buildThreadTitleRegenerationMenuItems({536 ...buildThreadTitleRegenerationMenuItems({
520 supported: props.titleRegenerationSupported,537 supported: props.titleRegenerationSupported,
...@@ -522,7 +539,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -522,7 +539,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
522 }),539 }),
523 THREAD_ROW_MENU_ACTIONS[1]!,540 THREAD_ROW_MENU_ACTIONS[1]!,
524 ],541 ],
525 [props.titleRegenerationSupported, thread.titleRegeneration],542 [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration],
526 );543 );
527 const primaryAction = useMemo(544 const primaryAction = useMemo(
528 () => ({545 () => ({
...@@ -535,11 +552,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -535,11 +552,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
535 );552 );
536 const handleMenuAction = useCallback(553 const handleMenuAction = useCallback(
537 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {554 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
555 if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
538 if (nativeEvent.event === "archive") handleArchive();556 if (nativeEvent.event === "archive") handleArchive();
539 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();557 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
540 if (nativeEvent.event === "delete") handleDelete();558 if (nativeEvent.event === "delete") handleDelete();
541 },559 },
542 [handleArchive, handleDelete, handleRegenerateTitle],560 [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread],
543 );561 );
544562
545 const statusPill = effectiveStatus ? (563 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: {...@@ -373,6 +373,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
373 readonly fullSwipeWidth?: number;373 readonly fullSwipeWidth?: number;
374 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;374 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
375 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;375 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
376 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
376 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;377 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
377 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;378 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
378 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;379 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
...@@ -412,6 +413,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -412,6 +413,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
412 onSelectThread,413 onSelectThread,
413 onDeleteThread,414 onDeleteThread,
414 onRegenerateThreadTitle,415 onRegenerateThreadTitle,
416 onNewThreadOnBranch,
415 onSettleThread,417 onSettleThread,
416 onSnoozeThread,418 onSnoozeThread,
417 onUnsnoozeThread,419 onUnsnoozeThread,
...@@ -589,6 +591,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -589,6 +591,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
589 );591 );
590 const handleMenuAction = useCallback(592 const handleMenuAction = useCallback(
591 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {593 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
594 if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
592 if (nativeEvent.event === "settle") handleSettle();595 if (nativeEvent.event === "settle") handleSettle();
593 if (nativeEvent.event === "unsettle") handleUnsettle();596 if (nativeEvent.event === "unsettle") handleUnsettle();
594 if (nativeEvent.event === "unsnooze") handleUnsnooze();597 if (nativeEvent.event === "unsnooze") handleUnsnooze();
...@@ -611,6 +614,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -611,6 +614,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
611 }614 }
612 },615 },
613 [616 [
617 onNewThreadOnBranch,
618 thread,
614 handleArchive,619 handleArchive,
615 handleDelete,620 handleDelete,
616 handleRegenerateTitle,621 handleRegenerateTitle,
...@@ -979,8 +984,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -979,8 +984,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
979 >984 >
980 {(close) => (985 {(close) => (
981 <ControlPillMenu986 <ControlPillMenu
982 actions={987 actions={[
983 snoozedRow988 ...(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
984 ? snoozedMenuActions1001 ? snoozedMenuActions
985 : !props.settlementSupported1002 : !props.settlementSupported
986 ? legacyMenuActions1003 ? legacyMenuActions
...@@ -988,8 +1005,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -988,8 +1005,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
988 ? slimMenuActions1005 ? slimMenuActions
989 : swipeActions.secondary === "snooze"1006 : swipeActions.secondary === "snooze"
990 ? snoozableCardMenuActions1007 ? snoozableCardMenuActions
991 : cardMenuActions1008 : cardMenuActions),
992 }1009 ]}
993 onPressAction={handleMenuAction}1010 onPressAction={handleMenuAction}
994 shouldOpenOnLongPress1011 shouldOpenOnLongPress
995 >1012 >
apps/mobile/src/lib/projectThreadStartTurn.test.ts+35
...@@ -66,3 +66,38 @@ describe("project thread title", () => {...@@ -66,3 +66,38 @@ describe("project thread title", () => {
66 expect(input.message.text).toBe(text);66 expect(input.message.text).toBe(text);
67 });67 });
68});68});
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});