Skip to content
Draft
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
75 changes: 53 additions & 22 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,6 @@ export function NewTaskDraftScreen(props: {
});
const workspaceLabel = resolveNewTaskWorkspaceLabel({
workspaceMode: flow.workspaceMode,
worktreePath: flow.selectedWorktreePath,
});
const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;

Expand Down Expand Up @@ -1186,6 +1185,7 @@ export function NewTaskDraftScreen(props: {
draft.modelSelection ?? null,
) ?? flow.selectedModel;
const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
const executionScope = draft.executionScope ?? flow.executionScope;
const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
const initialMessageText = draft.text.trim();

Expand All @@ -1194,7 +1194,7 @@ export function NewTaskDraftScreen(props: {
!modelSelection ||
initialMessageText.length === 0 ||
flow.submitting ||
(workspaceMode === "worktree" && !selectedBranchName)
(executionScope === "coding" && workspaceMode === "worktree" && !selectedBranchName)
) {
return;
}
Expand Down Expand Up @@ -1299,6 +1299,7 @@ export function NewTaskDraftScreen(props: {
// until the write confirms it.
clearComposerDraftContent(draftKey, {
clearModelSelection: true,
clearExecutionScope: true,
clearWorkspaceSelection: true,
deferAttachmentCleanup: true,
});
Expand Down Expand Up @@ -1347,7 +1348,11 @@ export function NewTaskDraftScreen(props: {
!flow.submitting &&
pendingPastedTextAttachmentCount === 0 &&
!voiceInput.blocksSubmission &&
!(flow.workspaceMode === "worktree" && !flow.selectedBranchName);
!(
flow.executionScope === "coding" &&
flow.workspaceMode === "worktree" &&
!flow.selectedBranchName
);
const openDraftDocument = (attachment: ComposerDocumentAttachment) => {
// A draft attachment lives only in the draft. Without its key the screen would fall through
// to a remote lookup for bytes the server has never seen.
Expand Down Expand Up @@ -1514,30 +1519,56 @@ export function NewTaskDraftScreen(props: {
const workspaceControls = (
<View className="flex-row items-center gap-1 px-2">
<ComposerInlineControl
accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
accessibilityLabel={workspaceLabel}
accessibilityHint={
flow.executionScope === "coordinator"
? "Runs in an isolated coordination workspace without repository checkout"
: "Runs as a coding thread in its own Git worktree"
}
accessibilityLabel={`Execution scope: ${flow.executionScope === "coordinator" ? "Coordinator" : "Coding"}`}
disabled={isComposerInteractionLocked || voiceInput.isBusy}
iconNode={
<NewTaskWorkspaceIcon
workspaceMode={flow.workspaceMode}
worktreePath={flow.selectedWorktreePath}
/>
icon={flow.executionScope === "coordinator" ? "text.bubble" : "hammer"}
label={flow.executionScope === "coordinator" ? "Coordinator" : "Coding"}
maxWidth={136}
onPress={() =>
flow.setExecutionScope(flow.executionScope === "coordinator" ? "coding" : "coordinator")
}
label={workspaceLabel}
maxWidth={flow.workspaceMode === "local" ? 220 : 148}
onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
showChevron={false}
/>
{flow.executionScope === "coding" ? (
<>
<ComposerInlineControl
accessibilityHint={
flow.workspaceMode === "local"
? "Uses a server-assigned coding worktree"
: "Creates a new worktree and runs its setup script"
}
accessibilityLabel={workspaceLabel}
disabled={isComposerInteractionLocked || voiceInput.isBusy}
iconNode={
<NewTaskWorkspaceIcon
workspaceMode={flow.workspaceMode}
worktreePath={flow.selectedWorktreePath}
/>
}
label={workspaceLabel}
maxWidth={flow.workspaceMode === "local" ? 220 : 148}
onPress={() =>
flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")
}
showChevron={false}
/>

<ComposerInlineControl
accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
chevronDirection="right"
disabled={isComposerInteractionLocked}
icon="arrow.triangle.branch"
label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
maxWidth={190}
onPress={() => openContextPicker("NewTaskBranch")}
/>
<ComposerInlineControl
accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
chevronDirection="right"
disabled={isComposerInteractionLocked}
icon="arrow.triangle.branch"
label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
maxWidth={190}
onPress={() => openContextPicker("NewTaskBranch")}
/>
</>
) : null}
</View>
);

Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
clearComposerDraftContent,
} from "../../state/use-composer-drafts";
import { useWorktreeSetup } from "./use-worktree-setup";
import { newThreadCreationPreparesWorktree } from "./new-task-context-presentation";
import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
import { ScreenHeader } from "../../components/ScreenHeader";
import { ScreenHeaderButton } from "../../components/ScreenHeaderButton";
Expand Down Expand Up @@ -817,7 +818,8 @@ function ThreadRouteContent(
threadId: selectedThread?.id ?? null,
activities: selectedThreadDetail?.activities ?? [],
preparing:
selectedThreadCreation?.message.creation?.workspaceMode === "worktree" &&
selectedThreadCreation !== null &&
newThreadCreationPreparesWorktree(selectedThreadCreation.message.creation) &&
selectedThreadCreation.outcome == null,
turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null,
followUpSent:
Expand Down Expand Up @@ -901,7 +903,7 @@ function ThreadRouteContent(
})
.catch((error) =>
Alert.alert(
"Could not work locally",
"Could not retry coding worktree",
error instanceof Error ? error.message : String(error),
),
);
Expand All @@ -919,7 +921,7 @@ function ThreadRouteContent(
}
return {
kind: "preparing",
preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree",
preparingWorktree: newThreadCreationPreparesWorktree(selectedThreadCreation.message.creation),
};
})();
if (!environmentId || !threadId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import { describe, expect, it } from "vite-plus/test";

import {
newThreadCreationPreparesWorktree,
resolveNewTaskBranchWorktreePath,
resolveNewTaskBranchLabel,
resolveNewTaskLocalWorkspaceSelection,
} from "./new-task-context-presentation";

describe("newThreadCreationPreparesWorktree", () => {
it("reports worktree preparation for local-mode coding and legacy coding creations", () => {
expect(
newThreadCreationPreparesWorktree({ executionScope: "coding", workspaceMode: "local" }),
).toBe(true);
expect(newThreadCreationPreparesWorktree({ workspaceMode: "local" })).toBe(true);
});

it("does not report repository setup for Coordinator creation", () => {
expect(
newThreadCreationPreparesWorktree({
executionScope: "coordinator",
workspaceMode: "worktree",
}),
).toBe(false);
expect(newThreadCreationPreparesWorktree(null)).toBe(false);
});
});

describe("resolveNewTaskLocalWorkspaceSelection", () => {
it("waits for refs instead of carrying a worktree base into Current checkout", () => {
expect(
Expand Down
18 changes: 15 additions & 3 deletions apps/mobile/src/features/threads/new-task-context-presentation.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
type WorkspaceMode = "local" | "worktree";

/** Every newly created coding thread receives a server-owned worktree. */
export function newThreadCreationPreparesWorktree(
input:
| {
readonly executionScope?: "coding" | "coordinator" | null;
readonly workspaceMode?: WorkspaceMode;
}
| null
| undefined,
): boolean {
return input !== null && input !== undefined && input.executionScope !== "coordinator";
}

export function resolveNewTaskWorkspaceLabel(input: {
readonly workspaceMode: WorkspaceMode;
readonly worktreePath: string | null;
}): "Current checkout" | "Current worktree" | "New worktree" {
}): "Coding worktree" | "New worktree" {
if (input.workspaceMode === "worktree") {
return "New worktree";
}
return input.worktreePath ? "Current worktree" : "Current checkout";
return "Coding worktree";
}

export function resolveNewTaskBranchWorktreePath(input: {
Expand Down
34 changes: 28 additions & 6 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ type NewTaskFlowContextValue = {
readonly selectedProjectKey: string | null;
readonly selectedModelKey: string | null;
readonly workspaceMode: WorkspaceMode;
readonly executionScope: "coding" | "coordinator";
readonly selectedBranchName: string | null;
readonly selectedWorktreePath: string | null;
readonly startFromOrigin: boolean;
Expand Down Expand Up @@ -185,6 +186,7 @@ type NewTaskFlowContextValue = {
options?: ReadonlyArray<ProviderOptionSelection>,
) => void;
readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
readonly setExecutionScope: (scope: "coding" | "coordinator") => void;
readonly selectBranch: (branch: VcsRef) => void;
readonly setStartFromOrigin: (value: boolean) => void;
readonly beginEditingPendingTask: (messageId: string) => boolean;
Expand Down Expand Up @@ -457,6 +459,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
projectSettings.sources.defaultThreadEnvMode !== "environment" ||
!t3ProjectFileQuery.isPending;
const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
const executionScope = selectedProjectDraft.executionScope ?? "coding";
const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
// Keep the user's explicit choice separate from the resolved display value:
Expand Down Expand Up @@ -781,6 +784,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
],
);

const setExecutionScope = useCallback(
(scope: "coding" | "coordinator") => {
if (!selectedProjectDraftKey) return;
updateComposerDraftSettings(selectedProjectDraftKey, { executionScope: scope });
},
[selectedProjectDraftKey],
);

useEffect(() => {
if (
workspaceMode !== "local" ||
Expand Down Expand Up @@ -933,6 +944,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
modelSelection: message.modelSelection,
runtimeMode: message.runtimeMode,
interactionMode: message.interactionMode,
executionScope: message.creation.executionScope ?? "coding",
workspaceSelection: {
mode: message.creation.workspaceMode,
branch: message.creation.branch,
Expand Down Expand Up @@ -976,6 +988,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
// Fall back to the resolved mode (server default) so queued tasks drain
// with the same mode the composer displayed.
const mode = workspaceSelection?.mode ?? workspaceMode;
const scope = draft.executionScope ?? executionScope;
// When the selection is the stand-in built from the queued snapshot,
// persist the original (possibly absent) snapshot values — the
// stand-in's placeholder title/workspaceRoot must never be written back
Expand Down Expand Up @@ -1011,16 +1024,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
...(projectTitle !== undefined ? { projectTitle } : {}),
...(projectCwd !== undefined ? { projectCwd } : {}),
workspaceMode: mode,
executionScope: scope,
// An explicit picker choice wins. Otherwise only a task sending now
// records the current checkout: a queued local task drains days
// later against whatever is checked out then, so a queue-time
// guess would pin a stale label to a thread that ran somewhere else.
branch: resolveProjectThreadCreationBranch({
workspaceMode: mode,
selectedBranch: workspaceSelection?.branch ?? null,
currentCheckoutBranch: options?.currentCheckoutBranch ?? null,
}),
worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),
branch:
scope === "coordinator"
? null
: resolveProjectThreadCreationBranch({
workspaceMode: mode,
selectedBranch: workspaceSelection?.branch ?? null,
currentCheckoutBranch: options?.currentCheckoutBranch ?? null,
}),
worktreePath: null,
// The draft only carries the flag when the user touched it; fall
// back to the resolved default (server settings) so queued tasks
// drain with the same origin mode the composer displayed.
Expand All @@ -1042,6 +1059,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
legacyPlanModeEnabled,
planModePreferenceLoaded,
startFromOrigin,
executionScope,
workspaceMode,
],
);
Expand Down Expand Up @@ -1155,6 +1173,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedProjectKey,
selectedModelKey,
workspaceMode,
executionScope,
selectedBranchName,
selectedWorktreePath,
startFromOrigin,
Expand Down Expand Up @@ -1188,6 +1207,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectEnvironment,
setSelectedModelKey,
setWorkspaceMode,
setExecutionScope,
selectBranch,
setStartFromOrigin,
beginEditingPendingTask,
Expand Down Expand Up @@ -1220,6 +1240,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
cancelEditingPendingTask,
currentCheckoutBranchName,
editingPendingTask,
executionScope,
environments,
expandedProvider,
filteredBranches,
Expand Down Expand Up @@ -1257,6 +1278,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
setSelectedModelKey,
setStartFromOrigin,
setWorkspaceMode,
setExecutionScope,
startFromOrigin,
submitting,
workspaceMode,
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/worktree-setup-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ function SetupDetailsSheet({
}}
className="min-h-11 justify-center px-2"
>
<Text className="text-sm text-foreground">Work locally</Text>
<Text className="text-sm text-foreground">Retry as coding worktree</Text>
</Pressable>
) : null}
</View>
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/composerContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ describe("host context compatibility", () => {
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
runtimeMode: "full-access",
interactionMode: "default",
executionScope: "coding",
workspaceMode: "local",
branch: null,
worktreePath: null,
Expand Down
Loading
Loading