| author | |
| committer | |
| log | 7dda0b1c0a95603f1954b81a9fee699be92668f0 |
| tree | eb042a6439c8f7255d243ac1d8bbf06ce3381761 |
| parent | e3b644c5af878ef5d9636153401dfd4d12ee6e81 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 129 | 130 | ||
| ... | @@ -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 | <ThreadListV2Row | 827 | <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 | <ThreadListRow | 1006 | <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]); |
| 451 | 451 | ||
| 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"; |
| 37 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 37 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 38 | import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; | 38 | import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; |
| 39 | 39 | ||
| 40 | function SelectionRow(props: { | 40 | function 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(); |
| 258 | 258 | ||
| 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 | } |
| 292 | 278 | ||
| 293 | // The checkout has already changed the repository. Persist the matching | 279 | // The checkout has already changed the repository. Persist the matching |
| 294 | // draft selection even if the native sheet was dismissed while the | 280 | // 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 @@ |
| 1 | import type { StaticScreenProps } from "@react-navigation/native"; | 1 | import { useNavigation, usePreventRemove, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { useMemo } from "react"; | 2 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 3 | import { Alert, View } from "react-native"; | ||
| 4 | import { | ||
| 5 | isAtomCommandInterrupted, | ||
| 6 | squashAtomCommandFailure, | ||
| 7 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 8 | import { AppText as Text } from "../../components/AppText"; | ||
| 9 | import { useProjects } from "../../state/entities"; | ||
| 10 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 11 | import { useWorkspaceState } from "../../state/workspace"; | ||
| 12 | import { vcsEnvironment } from "../../state/vcs"; | ||
| 13 | import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; | ||
| 3 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 14 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 4 | 15 | ||
| 5 | import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; | 16 | import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; |
| ... | @@ -7,6 +18,8 @@ import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; | ... | @@ -7,6 +18,8 @@ import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; |
| 7 | type NewTaskDraftRouteParams = { | 18 | type 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 | }; |
| 15 | 28 | ||
| 16 | export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) { | 29 | export 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 }); | ||
| 18 | 39 | ||
| 19 | // Keyed on the params object so a fresh navigation to this (already | 40 | // Keyed on the params object so a fresh navigation to this (already |
| 20 | // mounted) screen produces a new reference, letting the draft screen | 41 | // 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 | ); |
| 31 | 54 | ||
| 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 | <NativeStackScreenOptions | 149 | <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 | <NewTaskDraftScreen | 154 | {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.incomingShareId | 157 | </View> |
| 43 | } | 158 | ) : ( |
| 44 | pendingTaskId={ | 159 | <NewTaskDraftScreen |
| 45 | Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId | 160 | 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 | <ThreadListV2Row | 893 | <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 | <ThreadListRow | 1028 | <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 @@ | ||
| 1 | import * as NodeChildProcess from "node:child_process"; | ||
| 2 | import * as NodeFSP from "node:fs/promises"; | ||
| 3 | import * as NodeOS from "node:os"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import * as NodeUtil from "node:util"; | ||
| 6 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 7 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 8 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; | ||
| 11 | |||
| 12 | const exec = NodeUtil.promisify(NodeChildProcess.execFile); | ||
| 13 | let directory: string; | ||
| 14 | let cwd: string; | ||
| 15 | const git = (...args: string[]) => exec("git", ["-C", cwd, ...args]); | ||
| 16 | const branch = { name: "feature/a", current: false, isDefault: false, worktreePath: null }; | ||
| 17 | const environmentId = EnvironmentId.make("branch-test-environment"); | ||
| 18 | |||
| 19 | beforeEach(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 | |||
| 34 | afterEach(async () => { | ||
| 35 | await NodeFSP.rm(directory, { recursive: true, force: true }); | ||
| 36 | }); | ||
| 37 | |||
| 38 | function 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 | |||
| 47 | const 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 | |||
| 55 | describe("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 @@ | ||
| 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; | ||
| 3 | import { | ||
| 4 | type AtomCommandResult, | ||
| 5 | mapAtomCommandResult, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import type { VcsSwitchRefInput, VcsSwitchRefResult } from "@t3tools/contracts"; | ||
| 8 | import * as Cause from "effect/Cause"; | ||
| 9 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 10 | |||
| 11 | import { 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. */ | ||
| 15 | export 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"; |
| 8 | import type { MenuAction } from "@react-native-menu/menu"; | 8 | import type { MenuAction } from "@react-native-menu/menu"; |
| 9 | import { SymbolView } from "../../components/AppSymbol"; | 9 | import { SymbolView } from "../../components/AppSymbol"; |
| 10 | import { memo, useCallback, useMemo, type ComponentProps } from "react"; | 10 | import { memo, useCallback, useMemo, type ComponentProps } from "react"; |
| 11 | import { Pressable, useWindowDimensions, View } from "react-native"; | 11 | import { Platform, Pressable, useWindowDimensions, View } from "react-native"; |
| 12 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 12 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 13 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 13 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 14 | import Svg, { Circle, Path } from "react-native-svg"; | 14 | import 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"]; |
| 478 | 479 | ||
| 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 | ); |
| 544 | 562 | ||
| 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 | <ControlPillMenu | 986 | <ControlPillMenu |
| 982 | actions={ | 987 | actions={[ |
| 983 | snoozedRow | 988 | ...(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 | ? snoozedMenuActions | 1001 | ? snoozedMenuActions |
| 985 | : !props.settlementSupported | 1002 | : !props.settlementSupported |
| 986 | ? legacyMenuActions | 1003 | ? legacyMenuActions |
| ... | @@ -988,8 +1005,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -988,8 +1005,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 988 | ? slimMenuActions | 1005 | ? slimMenuActions |
| 989 | : swipeActions.secondary === "snooze" | 1006 | : swipeActions.secondary === "snooze" |
| 990 | ? snoozableCardMenuActions | 1007 | ? snoozableCardMenuActions |
| 991 | : cardMenuActions | 1008 | : cardMenuActions), |
| 992 | } | 1009 | ]} |
| 993 | onPressAction={handleMenuAction} | 1010 | onPressAction={handleMenuAction} |
| 994 | shouldOpenOnLongPress | 1011 | 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 | |||
| 70 | describe("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 | }); |