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
11 changes: 11 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ interface HomeScreenProps {
readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
readonly onNewThreadInProject: (project: EnvironmentProject) => void;
}

Expand Down Expand Up @@ -824,6 +825,7 @@ export function HomeScreen(props: HomeScreenProps) {
const movedId = `${thread.environmentId}:${thread.id}`;
return (
<ThreadListV2Row
onNewThreadOnBranch={props.onNewThreadOnBranch}
thread={thread}
variant={item.item.variant}
hasQueuedMessages={queuedThreadKeys.has(movedId)}
Expand Down Expand Up @@ -911,6 +913,7 @@ export function HomeScreen(props: HomeScreenProps) {
props.onDeletePendingTask,
props.onSelectPendingTask,
props.onSelectThread,
props.onNewThreadOnBranch,
props.savedConnectionsById,
serverConfigs,
shelfPreferencesLoaded,
Expand Down Expand Up @@ -1001,6 +1004,7 @@ export function HomeScreen(props: HomeScreenProps) {
const thread = item.thread;
return (
<ThreadListRow
onNewThreadOnBranch={props.onNewThreadOnBranch}
variant="compact"
thread={thread}
hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
Expand Down Expand Up @@ -1050,6 +1054,7 @@ export function HomeScreen(props: HomeScreenProps) {
props.onNewThreadInProject,
props.onSelectPendingTask,
props.onSelectThread,
props.onNewThreadOnBranch,
props.searchQuery,
props.savedConnectionsById,
threadSearchMatchByKey,
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,21 @@ function AdaptiveWorkspaceLayoutContent(
});
}, [navigation]);

const handleNewThreadOnBranch = useCallback(
(thread: EnvironmentThreadShell) => {
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", {
Expand Down Expand Up @@ -543,6 +558,7 @@ function AdaptiveWorkspaceLayoutContent(
onOpenSettings={handleOpenSettings}
onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
onNewThreadInProject={handleNewThreadInProject}
onNewThreadOnBranch={handleNewThreadOnBranch}
onSelectThread={handleSelectThread}
onSearchQueryChange={setPrimarySidebarSearchQuery}
searchQuery={primarySidebarSearchQuery}
Expand Down
46 changes: 16 additions & 30 deletions apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
149 changes: 135 additions & 14 deletions apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
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";

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[];
readonly incomingShareId?: string | string[];
};

export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {
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
Expand All @@ -25,27 +46,127 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
? params.environmentId[0]
: params.environmentId,
projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
branch: params.branch,
worktreePath: params.worktreePath,
}),
[route.params],
[params],
);

const [preparation, setPreparation] = useState<{
request: typeof initialProjectRef;
result: Awaited<ReturnType<typeof checkoutNewTaskBranch>>;
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 (
<>
<NativeStackScreenOptions
options={{
title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"),
}}
/>
<NewTaskDraftScreen
initialProjectRef={initialProjectRef}
incomingShareId={
Array.isArray(params.incomingShareId) ? params.incomingShareId[0] : params.incomingShareId
}
pendingTaskId={
Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
}
draftId={Array.isArray(params.draftId) ? params.draftId[0] : params.draftId}
/>
{preparingBranch ? (
<View className="flex-1 items-center justify-center bg-screen">
<Text className="text-foreground">Switching branch...</Text>
</View>
) : (
<NewTaskDraftScreen
initialProjectRef={preparedProjectRef}
incomingShareId={
Array.isArray(params.incomingShareId)
? params.incomingShareId[0]
: params.incomingShareId
}
pendingTaskId={pendingTaskId}
draftId={draftId}
/>
)}
</>
);
}
24 changes: 24 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
getComposerDraftSnapshot,
mergeComposerDraftContent,
restoreComposerDraftSnapshot,
updateComposerDraftSettings,
scheduleUnusedComposerAttachmentCleanup,
type ComposerDraft,
waitForComposerDraftsLoaded,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -569,6 +592,7 @@ export function NewTaskDraftScreen(props: {
}, [
projectScopes,
projects,
flow.draftKey,
props.initialProjectRef,
props.incomingShareId,
props.pendingTaskId,
Expand Down
Loading
Loading