diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 4cf787d9ce5..97eeb7595aa 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -39,6 +39,7 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";
import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen";
+import { NewTaskCheckoutScreen } from "./features/threads/NewTaskCheckoutScreen";
import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
@@ -231,6 +232,14 @@ const NewTaskSheetStack = createNativeStackNavigator({
// header also lays the content out below the bar (no manual inset).
options: SHEET_SOLID_HEADER_OPTIONS,
}),
+ NewTaskCheckout: createNativeStackScreen({
+ screen: NewTaskCheckoutScreen,
+ linking: "checkout",
+ options: {
+ ...SHEET_SOLID_HEADER_OPTIONS,
+ title: "Branches & PRs",
+ },
+ }),
AddProject: createNativeStackScreen({
screen: AddProjectSourceRoute,
linking: "add-project",
diff --git a/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx b/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx
new file mode 100644
index 00000000000..cd01e720833
--- /dev/null
+++ b/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx
@@ -0,0 +1,257 @@
+import { useNavigation } from "@react-navigation/native";
+import { useMemo, useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ Pressable,
+ RefreshControl,
+ ScrollView,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import type { VcsRef } from "@t3tools/contracts";
+import {
+ parsePullRequestReference,
+ resolveChangeRequestPresentation,
+} from "@t3tools/shared/sourceControl";
+
+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { useThemeColor } from "../../lib/useThemeColor";
+import { gitEnvironment } from "../../state/git";
+import { useEnvironmentQuery } from "../../state/query";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { vcsEnvironment } from "../../state/vcs";
+import { useNewTaskFlow, branchBadgeLabel } from "./new-task-flow-provider";
+import { resolveBranchCheckoutMode } from "./newTaskCheckoutSelection";
+
+function sectionLabel(value: string) {
+ return (
+
+ {value}
+
+ );
+}
+
+function CheckoutRow(props: {
+ readonly icon: "arrow.triangle.branch" | "arrow.triangle.pull";
+ readonly title: string;
+ readonly subtitle: string;
+ readonly badge?: string | null;
+ readonly disabled?: boolean;
+ readonly pending?: boolean;
+ readonly selected?: boolean;
+ readonly onPress: () => void;
+}) {
+ const iconColor = useThemeColor("--color-icon");
+ const subtleIconColor = useThemeColor("--color-icon-subtle");
+
+ return (
+
+
+
+
+
+
+
+ {props.title}
+
+ {props.badge ? (
+
+ {props.badge}
+
+ ) : null}
+
+
+ {props.subtitle}
+
+
+ {props.pending ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function branchSubtitle(branch: VcsRef): string {
+ if (branch.current) return "Use the project's current checkout";
+ if (branch.worktreePath) return "Reuse the existing worktree checkout";
+ if (branch.isRemote) return "Create a new worktree from this remote branch";
+ return "Create a new worktree from this branch";
+}
+
+export function NewTaskCheckoutScreen() {
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const flow = useNewTaskFlow();
+ const [preparingReference, setPreparingReference] = useState(null);
+ const selectedProject = flow.selectedProject;
+ const query = flow.branchQuery.trim();
+ const normalizedQuery = query.toLowerCase();
+ const parsedReference = parsePullRequestReference(query);
+ const filteredBranches = useMemo(() => {
+ if (!normalizedQuery) return flow.checkoutBranches;
+ return flow.checkoutBranches.filter((branch) =>
+ branch.name.toLowerCase().includes(normalizedQuery),
+ );
+ }, [flow.checkoutBranches, normalizedQuery]);
+ const preparePullRequest = useAtomCommand(gitEnvironment.preparePullRequestThread, {
+ reportFailure: false,
+ });
+ const gitStatus = useEnvironmentQuery(
+ selectedProject
+ ? vcsEnvironment.status({
+ environmentId: selectedProject.environmentId,
+ input: { cwd: selectedProject.workspaceRoot },
+ })
+ : null,
+ );
+ const currentBranch =
+ flow.availableBranches.find((branch) => branch.current) ??
+ flow.availableBranches.find((branch) => branch.isDefault) ??
+ null;
+ const sourceControlPresentation = resolveChangeRequestPresentation(
+ gitStatus.data?.sourceControlProvider,
+ );
+
+ const selectBranch = (branch: VcsRef) => {
+ flow.selectBranch(branch, resolveBranchCheckoutMode(branch));
+ flow.setBranchQuery("");
+ navigation.goBack();
+ };
+
+ const selectPullRequest = async (reference: string) => {
+ if (!selectedProject || preparingReference !== null) return;
+ setPreparingReference(reference);
+ const result = await preparePullRequest({
+ environmentId: selectedProject.environmentId,
+ input: {
+ cwd: selectedProject.workspaceRoot,
+ reference,
+ mode: "worktree",
+ },
+ });
+ setPreparingReference(null);
+
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ Alert.alert(
+ `Unable to prepare ${sourceControlPresentation.shortName}`,
+ error instanceof Error
+ ? error.message
+ : `The ${sourceControlPresentation.longName} could not be prepared.`,
+ );
+ }
+ return;
+ }
+
+ flow.selectPreparedCheckout({
+ branch: result.value.branch,
+ worktreePath: result.value.worktreePath,
+ });
+ flow.setBranchQuery("");
+ navigation.goBack();
+ };
+
+ const refreshing = flow.branchesLoading && flow.checkoutBranches.length === 0;
+
+ return (
+
+ {
+ void flow.loadBranches();
+ gitStatus.refresh();
+ }}
+ />
+ }
+ >
+
+
+
+ Existing checkouts are reused. Other branches and pull requests open in a new worktree.
+
+
+
+ {parsedReference ? (
+
+ {sectionLabel(`Open ${sourceControlPresentation.shortName}`)}
+ void selectPullRequest(parsedReference)}
+ />
+
+ ) : null}
+
+
+ {sectionLabel("Branches")}
+ {flow.branchesLoading && flow.availableBranches.length === 0 ? (
+
+
+
+ ) : null}
+ {!flow.branchesLoading && filteredBranches.length === 0 ? (
+ No matching branches.
+ ) : null}
+ {filteredBranches.map((branch) => {
+ const selected =
+ flow.selectedBranchName === branch.name &&
+ (flow.selectedWorktreePath ?? null) ===
+ (branch.worktreePath === selectedProject?.workspaceRoot
+ ? null
+ : branch.worktreePath);
+ return (
+ selectBranch(branch)}
+ />
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index ce81db8c486..a7190166c34 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -42,7 +42,7 @@ import {
restoreComposerDraftSnapshot,
type ComposerDraft,
} from "../../state/use-composer-drafts";
-import { useProjects } from "../../state/entities";
+import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
@@ -50,13 +50,18 @@ import { useRemoteConnectionStatus } from "../../state/use-remote-environment-re
import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
import { useCreateProjectThread } from "./use-project-actions";
import { useIncomingShare } from "../sharing/IncomingShareProvider";
+import { resolveNewTaskWorkspaceControl } from "./newTaskCheckoutSelection";
function formatWorkspaceLabel(input: {
readonly workspaceMode: string;
readonly currentBranchName: string | null;
readonly selectedBranchName: string | null;
+ readonly selectedWorktreePath: string | null;
}): string {
const branchName = input.selectedBranchName ?? input.currentBranchName;
+ if (input.selectedWorktreePath !== null) {
+ return branchName ? `Worktree · ${branchName}` : "Worktree";
+ }
if (input.workspaceMode === "worktree") {
return branchName ? `New worktree · ${branchName}` : "New worktree";
}
@@ -77,6 +82,9 @@ export function NewTaskDraftScreen(props: {
const createProjectThread = useCreateProjectThread();
const flow = useNewTaskFlow();
const navigation = useNavigation();
+ const selectedEnvironmentConfig = useEnvironmentServerConfig(
+ flow.selectedProject?.environmentId ?? null,
+ );
const {
consumeShare,
getShare,
@@ -695,10 +703,16 @@ export function NewTaskDraftScreen(props: {
formatWorkspaceLabel({
currentBranchName,
selectedBranchName: flow.selectedBranchName,
+ selectedWorktreePath: flow.selectedWorktreePath,
workspaceMode: flow.workspaceMode,
}),
- [currentBranchName, flow.selectedBranchName, flow.workspaceMode],
+ [currentBranchName, flow.selectedBranchName, flow.selectedWorktreePath, flow.workspaceMode],
);
+ const workspaceControl = resolveNewTaskWorkspaceControl({
+ checkoutAwareThreadCreationEnabled:
+ selectedEnvironmentConfig?.settings.enableCheckoutAwareThreadCreation ?? false,
+ hasProject: flow.selectedProject !== null,
+ });
function handleModelMenuAction(event: string) {
if (isIncomingShareTransferPending || !event.startsWith("model:")) {
return;
@@ -1014,17 +1028,28 @@ export function NewTaskDraftScreen(props: {
label={selectedEnvironmentLabel}
/>
- handleWorkspaceMenuAction(nativeEvent.event)}
- >
+ {workspaceControl === "checkout-picker" ? (
navigation.dispatch(StackActions.push("NewTaskCheckout"))}
+ showChevron={false}
/>
-
+ ) : (
+ handleWorkspaceMenuAction(nativeEvent.event)}
+ >
+
+
+ )}
>
);
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index ee2bde9d971..64fd25b70d0 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -17,6 +17,7 @@ import {
} from "@t3tools/contracts";
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
+import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
import type { TurnCommandMetadata } from "../../lib/commandMetadata";
@@ -122,6 +123,7 @@ type NewTaskFlowContextValue = {
readonly branchQuery: string;
readonly branchesLoading: boolean;
readonly availableBranches: ReadonlyArray;
+ readonly checkoutBranches: ReadonlyArray;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
readonly expandedProvider: string | null;
@@ -141,7 +143,11 @@ type NewTaskFlowContextValue = {
readonly selectEnvironment: (environmentId: EnvironmentId) => void;
readonly setSelectedModelKey: (key: string | null) => void;
readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
- readonly selectBranch: (branch: VcsRef) => void;
+ readonly selectBranch: (branch: VcsRef, modeOverride?: WorkspaceMode) => void;
+ readonly selectPreparedCheckout: (input: {
+ readonly branch: string;
+ readonly worktreePath: string | null;
+ }) => void;
readonly setStartFromOrigin: (value: boolean) => void;
readonly beginEditingPendingTask: (messageId: string) => boolean;
readonly finishEditingPendingTask: () => void;
@@ -475,13 +481,17 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
);
const branchState = useBranches(branchTarget);
const branchesLoading = branchState.isPending;
+ const checkoutBranches = useMemo(
+ () => dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []),
+ [branchState.data?.refs],
+ );
const availableBranches = useMemo(
() =>
pipe(
- branchState.data?.refs ?? [],
+ checkoutBranches,
Arr.filter((branch) => !branch.isRemote),
),
- [branchState.data?.refs],
+ [checkoutBranches],
);
const filteredBranches = useMemo(() => {
@@ -551,13 +561,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
);
const selectBranch = useCallback(
- (branch: VcsRef) => {
+ (branch: VcsRef, modeOverride?: WorkspaceMode) => {
if (!selectedProject || !selectedProjectDraftKey) {
return;
}
updateComposerDraftSettings(selectedProjectDraftKey, {
workspaceSelection: {
- mode: workspaceMode,
+ mode: modeOverride ?? workspaceMode,
branch: branch.name,
worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),
startFromOrigin,
@@ -567,6 +577,23 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
[selectedProject, selectedProjectDraftKey, startFromOrigin, workspaceMode],
);
+ const selectPreparedCheckout = useCallback(
+ (input: { readonly branch: string; readonly worktreePath: string | null }) => {
+ if (!selectedProjectDraftKey) return;
+ updateComposerDraftSettings(selectedProjectDraftKey, {
+ workspaceSelection: {
+ // The PR has already been materialized. Reuse that checkout rather
+ // than asking thread bootstrap to create a second worktree.
+ mode: "local",
+ branch: input.branch,
+ worktreePath: input.worktreePath,
+ startFromOrigin: false,
+ },
+ });
+ },
+ [selectedProjectDraftKey],
+ );
+
const setStartFromOrigin = useCallback(
(value: boolean) => {
if (!selectedProjectDraftKey) {
@@ -818,6 +845,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
branchQuery,
branchesLoading,
availableBranches,
+ checkoutBranches,
runtimeMode,
interactionMode,
expandedProvider,
@@ -835,6 +863,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
setSelectedModelKey,
setWorkspaceMode,
selectBranch,
+ selectPreparedCheckout,
setStartFromOrigin,
beginEditingPendingTask,
finishEditingPendingTask,
@@ -861,6 +890,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
branchesLoading,
buildPendingTaskMessage,
cancelEditingPendingTask,
+ checkoutBranches,
editingPendingTask,
environments,
expandedProvider,
@@ -882,6 +912,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedModelOption,
selectedProjectDraftKey,
selectedProviderSkills,
+ selectPreparedCheckout,
setSelectedModelOptions,
selectedProject,
selectedProjectKey,
diff --git a/apps/mobile/src/features/threads/newTaskCheckoutSelection.test.ts b/apps/mobile/src/features/threads/newTaskCheckoutSelection.test.ts
new file mode 100644
index 00000000000..bf7e9377444
--- /dev/null
+++ b/apps/mobile/src/features/threads/newTaskCheckoutSelection.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ resolveBranchCheckoutMode,
+ resolveNewTaskWorkspaceControl,
+} from "./newTaskCheckoutSelection";
+
+describe("resolveNewTaskWorkspaceControl", () => {
+ it("uses the checkout picker when the fork flag is enabled", () => {
+ expect(
+ resolveNewTaskWorkspaceControl({
+ checkoutAwareThreadCreationEnabled: true,
+ hasProject: true,
+ }),
+ ).toBe("checkout-picker");
+ });
+
+ it("preserves the legacy menu when the fork flag is disabled", () => {
+ expect(
+ resolveNewTaskWorkspaceControl({
+ checkoutAwareThreadCreationEnabled: false,
+ hasProject: true,
+ }),
+ ).toBe("legacy-menu");
+ });
+});
+
+describe("resolveBranchCheckoutMode", () => {
+ it("reuses an existing checkout", () => {
+ expect(
+ resolveBranchCheckoutMode({
+ name: "feature/existing",
+ current: false,
+ isDefault: false,
+ worktreePath: "/repo-worktrees/existing",
+ }),
+ ).toBe("local");
+ });
+
+ it("creates a worktree for a branch that is not checked out", () => {
+ expect(
+ resolveBranchCheckoutMode({
+ name: "feature/remote",
+ current: false,
+ isDefault: false,
+ worktreePath: null,
+ }),
+ ).toBe("worktree");
+ });
+});
diff --git a/apps/mobile/src/features/threads/newTaskCheckoutSelection.ts b/apps/mobile/src/features/threads/newTaskCheckoutSelection.ts
new file mode 100644
index 00000000000..191c4db84fe
--- /dev/null
+++ b/apps/mobile/src/features/threads/newTaskCheckoutSelection.ts
@@ -0,0 +1,14 @@
+import type { VcsRef } from "@t3tools/contracts";
+
+export function resolveNewTaskWorkspaceControl(input: {
+ readonly checkoutAwareThreadCreationEnabled: boolean;
+ readonly hasProject: boolean;
+}): "checkout-picker" | "legacy-menu" {
+ return input.checkoutAwareThreadCreationEnabled && input.hasProject
+ ? "checkout-picker"
+ : "legacy-menu";
+}
+
+export function resolveBranchCheckoutMode(branch: VcsRef): "local" | "worktree" {
+ return branch.current || branch.worktreePath !== null ? "local" : "worktree";
+}
diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts
index 3d7b5ff76a5..1ffb30d8c1d 100644
--- a/apps/server/src/sourceControl/GitHubCli.test.ts
+++ b/apps/server/src/sourceControl/GitHubCli.test.ts
@@ -93,7 +93,7 @@ describe("GitHubCli.layer", () => {
mergedAt: null,
isCrossRepository: true,
headRepository: {
- nameWithOwner: "octocat/codething-mvp",
+ name: "codething-mvp",
},
headRepositoryOwner: {
login: "octocat",
diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts
index d9dcb7f9ad1..94214ffdcb8 100644
--- a/apps/server/src/sourceControl/gitHubPullRequests.ts
+++ b/apps/server/src/sourceControl/gitHubPullRequests.ts
@@ -33,7 +33,8 @@ const GitHubPullRequestSchema = Schema.Struct({
headRepository: Schema.optional(
Schema.NullOr(
Schema.Struct({
- nameWithOwner: Schema.String,
+ name: Schema.optional(Schema.String),
+ nameWithOwner: Schema.optional(Schema.String),
}),
),
),
@@ -71,11 +72,18 @@ function normalizeGitHubPullRequestState(input: {
function normalizeGitHubPullRequestRecord(
raw: Schema.Schema.Type,
): NormalizedGitHubPullRequestRecord {
- const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner);
+ const explicitHeadRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner);
const headRepositoryOwnerLogin =
trimOptionalString(raw.headRepositoryOwner?.login) ??
- (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
- ? (headRepositoryNameWithOwner.split("/")[0] ?? null)
+ (typeof explicitHeadRepositoryNameWithOwner === "string" &&
+ explicitHeadRepositoryNameWithOwner.includes("/")
+ ? (explicitHeadRepositoryNameWithOwner.split("/")[0] ?? null)
+ : null);
+ const headRepositoryName = trimOptionalString(raw.headRepository?.name);
+ const headRepositoryNameWithOwner =
+ explicitHeadRepositoryNameWithOwner ??
+ (headRepositoryOwnerLogin && headRepositoryName
+ ? `${headRepositoryOwnerLogin}/${headRepositoryName}`
: null);
return {
diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts
index b919e736cc0..b5de1122e04 100644
--- a/apps/web/src/pullRequestReference.ts
+++ b/apps/web/src/pullRequestReference.ts
@@ -1,59 +1 @@
-const GITHUB_PULL_REQUEST_URL_PATTERN =
- /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
-const GITLAB_MERGE_REQUEST_URL_PATTERN =
- /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i;
-const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN =
- /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i;
-const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
-const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i;
-const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i;
-const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i;
-
-function parseAzureDevOpsCheckoutReference(args: string): string | null {
- const parts = args.trim().split(/\s+/).filter(Boolean);
- for (const [index, part] of parts.entries()) {
- if (part === "--id" || part === "-i") {
- return parts[index + 1] ?? null;
- }
- if (part.startsWith("--id=")) {
- return part.slice("--id=".length) || null;
- }
- }
- return parts.find((part) => !part.startsWith("-")) ?? null;
-}
-
-export function parsePullRequestReference(input: string): string | null {
- const trimmed = input.trim();
- if (trimmed.length === 0) {
- return null;
- }
-
- const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
- const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed);
- const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
- const normalizedInput =
- ghCliCheckoutMatch?.[1]?.trim() ??
- glabCliCheckoutMatch?.[1]?.trim() ??
- (azureDevOpsCliCheckoutMatch?.[1]
- ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1])
- : null) ??
- trimmed;
- if (normalizedInput.length === 0) {
- return null;
- }
-
- const urlMatch =
- GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
- GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ??
- AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
- if (urlMatch?.[1]) {
- return normalizedInput;
- }
-
- const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(normalizedInput);
- if (numberMatch?.[1]) {
- return numberMatch[1];
- }
-
- return null;
-}
+export { parsePullRequestReference } from "@t3tools/shared/sourceControl";
diff --git a/docs/personal-fork-changes.md b/docs/personal-fork-changes.md
index 0e8dcaed51e..cb2ad403e25 100644
--- a/docs/personal-fork-changes.md
+++ b/docs/personal-fork-changes.md
@@ -38,6 +38,10 @@ Upstream PRs integrated into the fork are listed in
## Checkout-aware thread creation
+- On mobile, the new-task workspace control opens a searchable checkout picker. Existing checkouts
+ are reused, branches that are not checked out create a worktree, and a pull request URL/number can
+ be resolved into an isolated worktree. Turning off the flag restores the compact upstream
+ workspace menu.
- With New worktree selected, creating a chat from an existing worktree seeds the draft from that
worktree's branch without reusing its path. Creating a chat for a different project still uses that
project's main branch.
diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts
index 15a98dc7355..afbbc74b100 100644
--- a/packages/shared/src/sourceControl.ts
+++ b/packages/shared/src/sourceControl.ts
@@ -1,5 +1,54 @@
import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";
+const GITHUB_PULL_REQUEST_URL_PATTERN =
+ /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
+const GITLAB_MERGE_REQUEST_URL_PATTERN =
+ /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i;
+const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN =
+ /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i;
+const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
+const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i;
+const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i;
+const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i;
+
+function parseAzureDevOpsCheckoutReference(args: string): string | null {
+ const parts = args.trim().split(/\s+/).filter(Boolean);
+ for (const [index, part] of parts.entries()) {
+ if (part === "--id" || part === "-i") {
+ return parts[index + 1] ?? null;
+ }
+ if (part.startsWith("--id=")) {
+ return part.slice("--id=".length) || null;
+ }
+ }
+ return parts.find((part) => !part.startsWith("-")) ?? null;
+}
+
+export function parsePullRequestReference(input: string): string | null {
+ const trimmed = input.trim();
+ if (trimmed.length === 0) return null;
+
+ const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
+ const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed);
+ const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
+ const normalizedInput =
+ ghCliCheckoutMatch?.[1]?.trim() ??
+ glabCliCheckoutMatch?.[1]?.trim() ??
+ (azureDevOpsCliCheckoutMatch?.[1]
+ ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1])
+ : null) ??
+ trimmed;
+ if (normalizedInput.length === 0) return null;
+
+ const urlMatch =
+ GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
+ GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ??
+ AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
+ if (urlMatch?.[1]) return normalizedInput;
+
+ return PULL_REQUEST_NUMBER_PATTERN.exec(normalizedInput)?.[1] ?? null;
+}
+
export interface ChangeRequestPresentation {
readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";
readonly providerName: string;