Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
257 changes: 257 additions & 0 deletions apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text className="px-1 text-2xs font-t3-bold tracking-[1px] text-foreground-secondary uppercase">
{value}
</Text>
);
}

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 (
<Pressable
accessibilityRole="button"
className="min-h-[64px] flex-row items-center gap-3 rounded-[18px] border border-border bg-card px-4 py-3 disabled:opacity-[0.45]"
disabled={props.disabled}
onPress={props.onPress}
>
<View className="h-9 w-9 items-center justify-center rounded-full bg-subtle">
<SymbolView name={props.icon} size={16} tintColor={iconColor} type="monochrome" />
</View>
<View className="min-w-0 flex-1 gap-0.5">
<View className="flex-row items-center gap-2">
<Text className="min-w-0 flex-1 text-base font-t3-bold" numberOfLines={1}>
{props.title}
</Text>
{props.badge ? (
<Text className="text-2xs font-t3-bold tracking-[0.7px] text-foreground-muted uppercase">
{props.badge}
</Text>
) : null}
</View>
<Text className="text-xs leading-snug text-foreground-muted" numberOfLines={2}>
{props.subtitle}
</Text>
</View>
{props.pending ? (
<ActivityIndicator size="small" />
) : (
<SymbolView
name={props.selected ? "checkmark" : "chevron.right"}
size={13}
tintColor={subtleIconColor}
type="monochrome"
/>
)}
</Pressable>
);
}

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<string | null>(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 (
<View className="flex-1 bg-sheet">
<ScrollView
className="flex-1"
keyboardShouldPersistTaps="handled"
contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
contentContainerClassName="gap-5 px-5 pt-3"
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => {
void flow.loadBranches();
gitStatus.refresh();
}}
/>
}
>
<View className="gap-2">
<TextInput
accessibilityLabel="Search branches or pull requests"
autoCapitalize="none"
autoCorrect={false}
placeholder="Search branches, or enter a PR URL or #"
returnKeyType="search"
value={flow.branchQuery}
onChangeText={flow.setBranchQuery}
/>
<Text className="px-1 text-xs leading-snug text-foreground-muted">
Existing checkouts are reused. Other branches and pull requests open in a new worktree.
</Text>
</View>

{parsedReference ? (
<View className="gap-2">
{sectionLabel(`Open ${sourceControlPresentation.shortName}`)}
<CheckoutRow
icon="arrow.triangle.pull"
title={`Checkout ${query.startsWith("#") ? query : `#${parsedReference}`}`}
subtitle={`Resolve and open this ${sourceControlPresentation.longName} in a new worktree`}
disabled={preparingReference !== null}
pending={preparingReference === parsedReference}
onPress={() => void selectPullRequest(parsedReference)}
/>
</View>
) : null}

<View className="gap-2">
{sectionLabel("Branches")}
{flow.branchesLoading && flow.availableBranches.length === 0 ? (
<View className="items-center py-4">
<ActivityIndicator />
</View>
) : null}
{!flow.branchesLoading && filteredBranches.length === 0 ? (
<Text className="px-1 text-sm text-foreground-muted">No matching branches.</Text>
) : null}
{filteredBranches.map((branch) => {
const selected =
flow.selectedBranchName === branch.name &&
(flow.selectedWorktreePath ?? null) ===
(branch.worktreePath === selectedProject?.workspaceRoot
? null
: branch.worktreePath);
return (
<CheckoutRow
key={`${branch.isRemote ? "remote" : "local"}:${branch.name}`}
icon="arrow.triangle.branch"
title={branch.name}
subtitle={branchSubtitle(branch)}
badge={branchBadgeLabel({ branch, project: selectedProject })}
disabled={preparingReference !== null}
selected={
selected || (flow.selectedBranchName === null && branch === currentBranch)
}
onPress={() => selectBranch(branch)}
/>
);
})}
</View>
</ScrollView>
</View>
);
}
39 changes: 32 additions & 7 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,26 @@ 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";
import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
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";
}
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1014,17 +1028,28 @@ export function NewTaskDraftScreen(props: {
label={selectedEnvironmentLabel}
/>
</ControlPillMenu>
<ControlPillMenu
actions={workspaceMenuActions}
onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
>
{workspaceControl === "checkout-picker" ? (
<ComposerToolbarTrigger
accessibilityLabel="Workspace"
disabled={isIncomingShareTransferPending}
icon="point.topleft.down.curvedto.point.bottomright.up"
label={workspaceLabel}
onPress={() => navigation.dispatch(StackActions.push("NewTaskCheckout"))}
showChevron={false}
/>
</ControlPillMenu>
) : (
<ControlPillMenu
actions={workspaceMenuActions}
onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
>
<ComposerToolbarTrigger
accessibilityLabel="Workspace"
disabled={isIncomingShareTransferPending}
icon="point.topleft.down.curvedto.point.bottomright.up"
label={workspaceLabel}
/>
</ControlPillMenu>
)}
</>
);

Expand Down
Loading
Loading