diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index a9833d2d619f..00731ce5aeec 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -214,6 +214,17 @@ export function HomeRouteScreen() { onSelectThread={handleSelectThread} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} + onNewThreadOnBranch={(thread) => { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(thread.environmentId), + projectId: String(thread.projectId), + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }); + }} onNewThreadInProject={(project) => { navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ac31cbf7a20d..e628eea08e31 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -124,6 +124,7 @@ interface HomeScreenProps { readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; + readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; } @@ -824,6 +825,7 @@ export function HomeScreen(props: HomeScreenProps) { const movedId = `${thread.environmentId}:${thread.id}`; return ( { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(thread.environmentId), + projectId: String(thread.projectId), + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }); + }, + [navigation], + ); + const handleNewThreadInProject = useCallback( (project: EnvironmentProject) => { navigation.navigate("NewTaskSheet", { @@ -543,6 +558,7 @@ function AdaptiveWorkspaceLayoutContent( onOpenSettings={handleOpenSettings} onOpenEnvironmentSettings={handleOpenEnvironmentSettings} onNewThreadInProject={handleNewThreadInProject} + onNewThreadOnBranch={handleNewThreadOnBranch} onSelectThread={handleSelectThread} onSearchQueryChange={setPrimarySidebarSearchQuery} searchQuery={primarySidebarSearchQuery} diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 96bd7438057a..ef066feca5b5 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -35,7 +35,7 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; -import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; +import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; function SelectionRow(props: { readonly icon?: "arrow.triangle.branch" | ReactNode; @@ -257,43 +257,29 @@ export function NewTaskBranchPickerRouteScreen() { void Haptics.selectionAsync(); try { - let selectedBranch = branch; - const needsCheckout = shouldCheckoutNewTaskBranch({ - branchIsCurrent: branch.current, - branchWorktreePath: branch.worktreePath, + if (!flow.selectedProject) return; + setSwitchingBranchName(branch.name); + const result = await checkoutNewTaskBranch({ + branch, + project: flow.selectedProject, workspaceMode: flow.workspaceMode, + switchRef, }); - if (needsCheckout && flow.selectedProject) { - setSwitchingBranchName(branch.name); - const result = await switchRef({ - environmentId: flow.selectedProject.environmentId, - input: { - cwd: flow.selectedProject.workspaceRoot, - refName: branch.name, - }, - }); - if (result._tag === "Failure") { - if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Alert.alert( - "Could not switch branch", - error instanceof Error ? error.message : "The branch could not be checked out.", - ); - } - return; + if (result._tag === "Failure") { + if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not switch branch", + error instanceof Error ? error.message : "The branch could not be checked out.", + ); } - selectedBranch = { - ...branch, - current: true, - isRemote: false, - name: result.value.refName ?? branch.name, - }; + return; } // The checkout has already changed the repository. Persist the matching // draft selection even if the native sheet was dismissed while the // command was in flight; only visible-screen work is focus-gated below. - flow.selectBranch(selectedBranch); + flow.selectBranch(result.value); if (!mountedRef.current || !navigation.isFocused()) { return; } diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index dc1ee942d13a..aab423a3792e 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -1,5 +1,16 @@ -import type { StaticScreenProps } from "@react-navigation/native"; -import { useMemo } from "react"; +import { useNavigation, usePreventRemove, type StaticScreenProps } from "@react-navigation/native"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert, View } from "react-native"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { AppText as Text } from "../../components/AppText"; +import { useProjects } from "../../state/entities"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useWorkspaceState } from "../../state/workspace"; +import { vcsEnvironment } from "../../state/vcs"; +import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; @@ -7,6 +18,8 @@ import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; type NewTaskDraftRouteParams = { readonly environmentId?: string | string[]; readonly projectId?: string | string[]; + readonly branch?: string | null; + readonly worktreePath?: string | null; readonly title?: string | string[]; readonly pendingTaskId?: string | string[]; readonly draftId?: string | string[]; @@ -14,7 +27,15 @@ type NewTaskDraftRouteParams = { }; export function NewTaskDraftRouteScreen({ route }: StaticScreenProps) { - const params = route.params ?? {}; + const params = useMemo(() => route.params ?? {}, [route.params]); + const pendingTaskId = Array.isArray(params.pendingTaskId) + ? params.pendingTaskId[0] + : params.pendingTaskId; + const draftId = Array.isArray(params.draftId) ? params.draftId[0] : params.draftId; + const projects = useProjects(); + const { state: catalogState } = useWorkspaceState(); + const navigation = useNavigation(); + const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); // Keyed on the params object so a fresh navigation to this (already // mounted) screen produces a new reference, letting the draft screen @@ -25,10 +46,104 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps>; + workspaceRoot: string | undefined; + } | null>(null); + const project = projects.find( + (candidate) => + candidate.environmentId === initialProjectRef.environmentId && + candidate.id === initialProjectRef.projectId, + ); + const environmentId = project?.environmentId; + const workspaceRoot = project?.workspaceRoot; + const needsPreparation = Boolean(initialProjectRef.branch && !pendingTaskId && !draftId); + + const [pendingCheckouts, setPendingCheckouts] = useState(0); + const checkoutTail = useRef(Promise.resolve()); + const waitingForProject = + !project && + (catalogState.isLoadingConnections || + (!catalogState.hasLoadedShellSnapshot && + catalogState.hasConnectingEnvironment && + catalogState.connectionError === null)); + + useEffect(() => { + if (!needsPreparation || !initialProjectRef.branch || waitingForProject) return; + const branchName = initialProjectRef.branch; + let active = true; + setPendingCheckouts((count) => count + 1); + // Serialize replacements: ignoring a stale result cannot undo its Git mutation. + checkoutTail.current = checkoutTail.current.then(async () => { + if (!active) { + setPendingCheckouts((count) => count - 1); + return; + } + const result = await checkoutNewTaskBranch({ + // A thread's branch is historical; only switchRef can establish that + // the shared project checkout now matches it. + branch: { + name: branchName, + current: false, + isDefault: false, + worktreePath: initialProjectRef.worktreePath ?? null, + }, + project: environmentId && workspaceRoot ? { environmentId, workspaceRoot } : null, + workspaceMode: "local", + switchRef, + }); + setPendingCheckouts((count) => count - 1); + if (active) setPreparation({ request: initialProjectRef, result, workspaceRoot }); + }); + return () => { + active = false; + }; + }, [ + environmentId, + workspaceRoot, + initialProjectRef, + needsPreparation, + switchRef, + waitingForProject, + ]); + + const result = + preparation?.request === initialProjectRef && preparation.workspaceRoot === workspaceRoot + ? preparation.result + : null; + // The native-stack guard covers iOS swipe dismissal as well as back actions. + // A replaced request must settle too before the shared checkout is left behind. + const checkoutPending = pendingCheckouts > 0 || (needsPreparation && result === null); + usePreventRemove(checkoutPending, () => undefined); + useEffect(() => { + if (checkoutPending || result?._tag !== "Failure") return; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not switch branch", + error instanceof Error ? error.message : "The branch could not be checked out.", + ); + } + navigation.goBack(); + }, [checkoutPending, result, navigation]); + + const preparedProjectRef = useMemo( + () => + result?._tag === "Success" + ? { ...initialProjectRef, branch: result.value.name } + : initialProjectRef, + [initialProjectRef, result], + ); + // Send/queue remain unavailable on failure while the unlocked route closes. + const preparingBranch = checkoutPending || (needsPreparation && result?._tag !== "Success"); + return ( <> - + {preparingBranch ? ( + + Switching branch... + + ) : ( + + )} ); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7067f05fd010..3ccd0ec1713c 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -82,6 +82,7 @@ import { getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, + updateComposerDraftSettings, scheduleUnusedComposerAttachmentCleanup, type ComposerDraft, waitForComposerDraftsLoaded, @@ -149,6 +150,8 @@ export function NewTaskDraftScreen(props: { readonly initialProjectRef?: { readonly environmentId?: string; readonly projectId?: string; + readonly branch?: string | null; + readonly worktreePath?: string | null; }; /** Queued outbox message id when editing an existing pending task. */ readonly pendingTaskId?: string; @@ -536,6 +539,26 @@ export function NewTaskDraftScreen(props: { if (appliedInitialProjectKeyRef.current === directProjectKey) { return; } + if (props.initialProjectRef?.branch) { + if ( + selectedProject?.environmentId !== directProject.environmentId || + selectedProject.id !== directProject.id + ) { + setProject(directProject); + return; + } + if (!flow.draftKey) return; + // The route completes checkout before mounting this composer. Local + // mode reuses an existing worktree; worktree mode would create another. + updateComposerDraftSettings(flow.draftKey, { + workspaceSelection: { + mode: "local", + branch: props.initialProjectRef.branch, + worktreePath: props.initialProjectRef.worktreePath ?? null, + startFromOrigin: false, + }, + }); + } appliedInitialProjectKeyRef.current = directProjectKey; if ( selectedProject?.environmentId === directProject.environmentId && @@ -569,6 +592,7 @@ export function NewTaskDraftScreen(props: { }, [ projectScopes, projects, + flow.draftKey, props.initialProjectRef, props.incomingShareId, props.pendingTaskId, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index f469ada259c9..d3cd65fe8a7b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -103,6 +103,7 @@ interface ThreadNavigationSidebarProps { readonly selectedThreadKey: string | null; readonly onOpenSettings: () => void; readonly onOpenEnvironmentSettings: () => void; + readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; readonly onSearchQueryChange: (query: string) => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; @@ -890,6 +891,7 @@ function ThreadNavigationSidebarPane( const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); return ( exec("git", ["-C", cwd, ...args]); +const branch = { name: "feature/a", current: false, isDefault: false, worktreePath: null }; +const environmentId = EnvironmentId.make("branch-test-environment"); + +beforeEach(async () => { + directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-branch-selection-")); + cwd = NodePath.join(directory, "project"); + await exec("git", ["init", "-b", "main", cwd]); + await git("config", "user.name", "Branch test"); + await git("config", "user.email", "branch-test@example.com"); + await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "main\n"); + await git("add", "."); + await git("commit", "-m", "main"); + await git("checkout", "-b", branch.name); + await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "feature\n"); + await git("commit", "-am", "feature"); + await git("checkout", "main"); +}); + +afterEach(async () => { + await NodeFSP.rm(directory, { recursive: true, force: true }); +}); + +function selectBranch(switchRef: Parameters[0]["switchRef"]) { + return checkoutNewTaskBranch({ + branch, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + switchRef, + }); +} + +const switchRef: Parameters[0]["switchRef"] = (request) => + settlePromise(async () => { + expect(request.environmentId).toBe(environmentId); + await exec("git", ["-C", request.input.cwd, "checkout", request.input.refName]); + const { stdout } = await exec("git", ["-C", request.input.cwd, "branch", "--show-current"]); + return { refName: stdout.trim() }; + }); + +describe("new-task branch checkout", () => { + it("switches main to the older thread's feature branch before returning a selection", async () => { + const result = await selectBranch(switchRef); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Checkout failed"); + expect(result.value.name).toBe("feature/a"); + expect(result.value.current).toBe(true); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a"); + expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe("feature\n"); + }); + + it("does not release the selection while checkout is still pending", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let completed = false; + const selection = selectBranch(async (request) => { + entered.resolve(); + await release.promise; + return switchRef(request); + }).then((result) => { + completed = true; + return result; + }); + await entered.promise; + expect(completed).toBe(false); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + release.resolve(); + expect((await selection)._tag).toBe("Success"); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a"); + }); + + it("returns checkout failure without selecting the branch or losing dirty files", async () => { + await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "unsaved local changes\n"); + const result = await selectBranch(switchRef); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected checkout to fail"); + expect(String(squashAtomCommandFailure(result))).toContain("would be overwritten"); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe( + "unsaved local changes\n", + ); + }); + + it("fails when the source project is unavailable instead of releasing a composer selection", async () => { + const result = await checkoutNewTaskBranch({ + branch, + project: null, + workspaceMode: "local", + switchRef: () => { + throw new Error("An unavailable project must not run checkout"); + }, + }); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected an unavailable-project failure"); + expect(String(squashAtomCommandFailure(result))).toContain("selected project is unavailable"); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + }); + + it("reuses an existing worktree without switching the project checkout", async () => { + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, "feature/a"); + const result = await checkoutNewTaskBranch({ + branch: { ...branch, worktreePath }, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + switchRef: () => { + throw new Error("Existing worktrees must not switch the project checkout"); + }, + }); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Worktree selection failed"); + expect(result.value.worktreePath).toBe(worktreePath); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe( + "feature\n", + ); + }); +}); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts new file mode 100644 index 000000000000..2652581f6e1b --- /dev/null +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -0,0 +1,49 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { + type AtomCommandResult, + mapAtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { VcsSwitchRefInput, VcsSwitchRefResult } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; + +/** Resolve a composer branch only after its checkout succeeds. Existing worktrees + * and new-worktree base selections already identify a separate workspace. */ +export async function checkoutNewTaskBranch(input: { + readonly branch: VcsRef; + readonly project: Pick | null; + readonly workspaceMode: "local" | "worktree"; + readonly switchRef: (request: { + readonly environmentId: EnvironmentProject["environmentId"]; + readonly input: VcsSwitchRefInput; + }) => Promise>; +}): Promise> { + if (!input.project) { + return AsyncResult.failure( + Cause.fail(new Error("The selected project is unavailable. Reconnect and try again.")), + ); + } + if ( + !shouldCheckoutNewTaskBranch({ + branchIsCurrent: input.branch.current, + branchWorktreePath: input.branch.worktreePath, + workspaceMode: input.workspaceMode, + }) + ) { + return AsyncResult.success(input.branch); + } + + const result = await input.switchRef({ + environmentId: input.project.environmentId, + input: { cwd: input.project.workspaceRoot, refName: input.branch.name }, + }); + return mapAtomCommandResult(result, (value) => ({ + ...input.branch, + current: true, + isRemote: false, + name: value.refName ?? input.branch.name, + })); +} diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index fa547e2cdf46..03443c9f7868 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -8,7 +8,7 @@ import type { EnvironmentMachineKind } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; -import { Pressable, useWindowDimensions, View } from "react-native"; +import { Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import Svg, { Circle, Path } from "react-native-svg"; @@ -453,6 +453,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly titleRegenerationSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; @@ -476,8 +477,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const selectedBackgroundColor = theme["--color-user-bubble"]; const selectedForegroundColor = theme["--color-user-bubble-foreground"]; - const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = - props; + const { + thread, + onSelectThread, + onArchiveThread, + onDeleteThread, + onRegenerateThreadTitle, + onNewThreadOnBranch, + } = props; const status = resolveThreadStatus(thread); const pr = useThreadPr(thread); const timestamp = relativeTime( @@ -515,6 +522,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const menuActions = useMemo( () => [ + ...(thread.branch + ? [ + { + id: "new-thread-on-branch", + title: + Platform.OS === "ios" ? "New thread on branch" : `New thread on ${thread.branch}`, + image: "square.and.pencil", + }, + ] + : []), THREAD_ROW_MENU_ACTIONS[0]!, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, @@ -522,7 +539,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { }), THREAD_ROW_MENU_ACTIONS[1]!, ], - [props.titleRegenerationSupported, thread.titleRegeneration], + [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], ); const primaryAction = useMemo( () => ({ @@ -535,11 +552,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleRegenerateTitle], + [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread], ); const statusPill = effectiveStatus ? ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 55eb5af0c5f8..2c35e24f3e87 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -373,6 +373,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; @@ -412,6 +413,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSelectThread, onDeleteThread, onRegenerateThreadTitle, + onNewThreadOnBranch, onSettleThread, onSnoozeThread, onUnsnoozeThread, @@ -589,6 +591,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); if (nativeEvent.event === "unsnooze") handleUnsnooze(); @@ -611,6 +614,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } }, [ + onNewThreadOnBranch, + thread, handleArchive, handleDelete, handleRegenerateTitle, @@ -979,8 +984,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {(close) => ( diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts index bd7918e52d79..57df389cff3e 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.test.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -66,3 +66,38 @@ describe("project thread title", () => { expect(input.message.text).toBe(text); }); }); + +describe("new thread on an existing branch", () => { + it.each([null, "/worktrees/existing"])( + "reuses the selected workspace %s without preparing a new worktree", + (worktreePath) => { + const input = buildProjectThreadStartTurnInput({ + projectId: ProjectId.make("project"), + projectCwd: "/workspace", + threadId: "new-thread", + commandId: "command", + messageId: "message", + createdAt: "2026-09-06T00:00:00Z", + text: "Start fresh", + uploadedAttachments: [], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + workspaceMode: "local", + branch: "feature/existing", + worktreePath, + startFromOrigin: false, + worktreeBranchName: "unused", + }); + + expect(input.bootstrap.createThread).toMatchObject({ + projectId: "project", + branch: "feature/existing", + worktreePath, + }); + expect(input.bootstrap).not.toHaveProperty("prepareWorktree"); + expect(input.bootstrap).not.toHaveProperty("runSetupScript"); + expect(input.threadId).toBe("new-thread"); + }, + ); +});