Remember the New Workspace isolation choice

The local/worktree choice was unstored component state and reset on every
remount of the New Workspace screen (e.g. after creating a workspace).
Persist it in the create-form preferences (FormPreferences.isolation).

Also rename the app-layer "backing" to "isolation" to match its UI label.
Isolation is the create-time intent; it stays distinct from workspaceKind,
the git-derived persisted property, and the wire's source.kind.
This commit is contained in:
Mohamed Boudra
2026-06-17 09:53:22 +07:00
parent 69741e0770
commit 7e5d7f7c67
16 changed files with 256 additions and 110 deletions
+2 -1
View File
@@ -4,7 +4,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
+20 -4
View File
@@ -209,18 +209,34 @@ export async function selectNewWorkspaceProject(
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
}
export async function selectWorkspaceBacking(
// The isolation trigger renders the active isolation's label ("Local" / "New
// worktree"), so asserting its text proves what the screen currently remembers.
const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = {
local: "Local",
worktree: "New worktree",
};
export async function expectWorkspaceIsolationSelected(
page: Page,
backing: "local" | "worktree",
isolation: "local" | "worktree",
): Promise<void> {
const trigger = page.getByTestId("workspace-create-backing-trigger");
const trigger = page.getByRole("button", { name: "Workspace isolation" });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]);
}
export async function selectWorkspaceIsolation(
page: Page,
isolation: "local" | "worktree",
): Promise<void> {
const trigger = page.getByTestId("workspace-create-isolation-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
// "New worktree" is only listed once the checkout status query confirms the
// selected project is a git repo, so wait for the option to appear before
// clicking it.
const option = page.getByTestId(`workspace-create-backing-${backing}`);
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
}
+3 -3
View File
@@ -131,9 +131,9 @@ test.describe("New workspace entry points", () => {
await nonGitOption.click();
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
// No git checkout means no worktree backing choice: the Isolation row is
// No git checkout means no worktree isolation choice: the Isolation row is
// absent entirely.
await expect(page.getByTestId("workspace-create-backing-trigger")).toHaveCount(0);
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
@@ -144,7 +144,7 @@ test.describe("New workspace entry points", () => {
await gitOption.click();
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
await expect(page.getByTestId("workspace-create-backing-trigger")).toBeVisible({
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({
timeout: 30_000,
});
} finally {
@@ -0,0 +1,96 @@
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
selectBranchInPicker,
selectWorkspaceIsolation,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Regression for "the local / worktree selection in the new workspace is not
// remembered." The isolation choice persists in the create-form preferences
// (FormPreferences.isolation), so it must survive the create→reopen remount:
// creating a worktree workspace navigates away from /new and unmounts it, and
// reopening New Workspace has to still show "New worktree".
test.describe("New workspace isolation memory", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
});
test.afterEach(async () => {
if (client) {
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
// First visit: the screen opens on Local, switch it to New worktree and create.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "local");
await selectWorkspaceIsolation(page, "worktree");
await expectWorkspaceIsolationSelected(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
// Second visit (fresh mount of /new): the worktree choice must stick.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "worktree");
} finally {
await tempRepo.cleanup();
}
});
});
+5 -5
View File
@@ -25,7 +25,7 @@ import {
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
selectWorkspaceBacking,
selectWorkspaceIsolation,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
@@ -633,7 +633,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceBacking(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
@@ -679,7 +679,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceBacking(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -704,7 +704,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceBacking(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -734,7 +734,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceBacking(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number);
@@ -6,7 +6,7 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectWorkspaceBacking,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
@@ -261,7 +261,7 @@ test.describe("Workspace model regressions", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local");
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspacePrompt(page, "Fix login bug");
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -346,7 +346,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local");
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -462,7 +462,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local");
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
+13 -13
View File
@@ -6,7 +6,7 @@ import {
connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
selectWorkspaceBacking,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
} from "./helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
@@ -14,8 +14,8 @@ import { expectExplorerEntryVisible } from "./helpers/file-explorer";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B reshape: a workspace is the unit, its backing directory (local
// checkout or worktree) is a CHOICE at creation, and creation NEVER dedupes by
// Model B reshape: a workspace is the unit, its isolation (local checkout or
// worktree) is a CHOICE at creation, and creation NEVER dedupes by
// directory. These specs drive the real creation UI (workspace-create-* test
// IDs) to prove a single directory can back any number of workspaces.
@@ -39,16 +39,16 @@ async function createWorkspaceViaUi(
input: {
project: { projectKey: string; projectDisplayName: string };
// null when the project has no git checkout: there is no Isolation control to
// touch, the backing is implicitly local.
backing: "local" | "worktree" | null;
// touch, the isolation is implicitly local.
isolation: "local" | "worktree" | null;
previousWorkspaceId: string;
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
},
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, input.project);
if (input.backing !== null) {
await selectWorkspaceBacking(page, input.backing);
if (input.isolation !== null) {
await selectWorkspaceIsolation(page, input.isolation);
}
await submitNewWorkspaceEmpty(page);
@@ -96,7 +96,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
backing: "local",
isolation: "local",
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -127,7 +127,7 @@ test.describe("Workspace multiplicity creation flow", () => {
}
});
test("New worktree backing creates a worktree-backed workspace in a distinct directory", async ({
test("New worktree isolation creates a worktree-backed workspace in a distinct directory", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({
@@ -148,13 +148,13 @@ test.describe("Workspace multiplicity creation flow", () => {
const worktree = await createWorkspaceViaUi(page, {
project,
backing: "worktree",
isolation: "worktree",
previousWorkspaceId: seeded.workspaceId,
client,
});
// The worktree row appears, pointing at a directory distinct from the
// backing checkout.
// local checkout.
const worktreeRow = page.getByTestId(workspaceRowTestId(worktree.workspaceId));
await expect(worktreeRow).toBeVisible({ timeout: 30_000 });
expect(worktree.workspaceId).not.toBe(seeded.workspaceId);
@@ -199,8 +199,8 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
// Non-git project: no Isolation control, backing is implicitly local.
backing: null,
// Non-git project: no Isolation control, isolation is implicitly local.
isolation: null,
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -84,4 +84,27 @@ describe("create agent preferences", () => {
it("loads invalid stored preferences as empty preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
});
it("persists and reloads the workspace isolation choice", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const save = preferences.update({ isolation: "worktree" });
await storage.nextWrite();
storage.finishOldestWrite();
await save;
expect(storage.savedPreferences()).toEqual({ isolation: "worktree" });
expect(await new CreateAgentPreferencesService(storage).load()).toEqual({
isolation: "worktree",
});
});
it("treats stored preferences without an isolation choice as undefined", () => {
expect(parseFormPreferences({ provider: "codex" }).isolation).toBeUndefined();
});
it("rejects an unknown isolation value as invalid stored preferences", () => {
expect(parseFormPreferences({ provider: "codex", isolation: "sandbox" })).toEqual({});
});
});
@@ -33,6 +33,7 @@ const formPreferencesSchema = z.object({
}),
)
.optional(),
isolation: z.enum(["local", "worktree"]).optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
+1 -1
View File
@@ -833,7 +833,7 @@ export const ar: TranslationResources = {
newWorkspace: {
title: "مساحة عمل جديدة",
create: "يخلق",
backing: {
isolation: {
local: "محلي",
worktree: "شجرة عمل جديدة",
label: "العزل",
+1 -1
View File
@@ -839,7 +839,7 @@ export const en = {
newWorkspace: {
title: "New workspace",
create: "Create",
backing: {
isolation: {
local: "Local",
worktree: "New worktree",
label: "Isolation",
+1 -1
View File
@@ -859,7 +859,7 @@ export const es: TranslationResources = {
newWorkspace: {
title: "Nuevo espacio de trabajo",
create: "Crear",
backing: {
isolation: {
local: "Local",
worktree: "Nuevo worktree",
label: "Aislamiento",
+1 -1
View File
@@ -858,7 +858,7 @@ export const fr: TranslationResources = {
newWorkspace: {
title: "Nouvel espace de travail",
create: "Créer",
backing: {
isolation: {
local: "Local",
worktree: "Nouveau worktree",
label: "Isolation",
+1 -1
View File
@@ -851,7 +851,7 @@ export const ru: TranslationResources = {
newWorkspace: {
title: "Новое рабочее пространство",
create: "Создавать",
backing: {
isolation: {
local: "Локально",
worktree: "Новый worktree",
label: "Изоляция",
+1 -1
View File
@@ -824,7 +824,7 @@ export const zhCN: TranslationResources = {
newWorkspace: {
title: "新建 workspace",
create: "创建",
backing: {
isolation: {
local: "本地",
worktree: "新建 worktree",
label: "隔离",
@@ -37,6 +37,7 @@ import { useDraftStore } from "@/stores/draft-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
@@ -375,7 +376,7 @@ function PickerOptionItem({
);
}
function BackingOptionItem({
function IsolationOptionItem({
optionId,
label,
selected,
@@ -408,7 +409,7 @@ function BackingOptionItem({
);
return (
<ComboboxItem
testID={`workspace-create-backing-${optionId}`}
testID={`workspace-create-isolation-${optionId}`}
label={label}
selected={selected}
active={active}
@@ -636,7 +637,7 @@ function IsolationPickerTrigger({
onPress,
disabled,
badgePressableStyle,
backing,
isolation,
label,
iconColor,
iconSize,
@@ -645,7 +646,7 @@ function IsolationPickerTrigger({
onPress: () => void;
disabled: boolean;
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
backing: "local" | "worktree";
isolation: "local" | "worktree";
label: string;
iconColor: string;
iconSize: number;
@@ -653,7 +654,7 @@ function IsolationPickerTrigger({
return (
<ComboboxTrigger
ref={pickerAnchorRef}
testID="workspace-create-backing-trigger"
testID="workspace-create-isolation-trigger"
onPress={onPress}
disabled={disabled}
style={badgePressableStyle}
@@ -661,7 +662,7 @@ function IsolationPickerTrigger({
accessibilityLabel="Workspace isolation"
>
<View style={styles.badgeIconBox}>
{backing === "worktree" ? (
{isolation === "worktree" ? (
<GitBranch size={iconSize} color={iconColor} />
) : (
<Folder size={iconSize} color={iconColor} />
@@ -680,45 +681,52 @@ function FormRow({ children }: { children: React.ReactNode }) {
return <View style={styles.row}>{children}</View>;
}
interface WorkspaceBackingState {
backing: "local" | "worktree";
setBacking: (value: "local" | "worktree") => void;
effectiveBacking: "local" | "worktree";
interface WorkspaceIsolationState {
isolation: "local" | "worktree";
setIsolation: (value: "local" | "worktree") => void;
effectiveIsolation: "local" | "worktree";
canCreateWorktree: boolean;
showRefPicker: boolean;
}
// Worktree backing only makes sense for a git checkout. The effective backing
// Worktree isolation only makes sense for a git checkout. The effective isolation
// falls back to local whenever the selected directory isn't git so the flow
// never submits an impossible request.
function useWorkspaceBacking(input: {
function useWorkspaceIsolation(input: {
supportsMultiplicity: boolean;
selectedIsGit: boolean;
}): WorkspaceBackingState {
}): WorkspaceIsolationState {
const { supportsMultiplicity, selectedIsGit } = input;
const [backing, setBacking] = useState<"local" | "worktree">("local");
// The last isolation choice is remembered alongside the other New Workspace
// form preferences (provider, model, mode). A manual in-screen pick overrides
// the remembered default until the screen remounts.
const { preferences, updatePreferences } = useFormPreferences();
const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null);
const isolation = manualIsolation ?? preferences.isolation ?? "local";
const canCreateWorktree = supportsMultiplicity && selectedIsGit;
const isWorktree = backing === "worktree" && canCreateWorktree;
const isWorktree = isolation === "worktree" && canCreateWorktree;
useEffect(() => {
if (backing === "worktree" && !canCreateWorktree) {
setBacking("local");
}
}, [backing, canCreateWorktree]);
const setIsolation = useCallback(
(value: "local" | "worktree") => {
setManualIsolation(value);
void updatePreferences({ isolation: value });
},
[updatePreferences],
);
return {
backing,
setBacking,
effectiveBacking: isWorktree ? "worktree" : "local",
isolation,
setIsolation,
effectiveIsolation: isWorktree ? "worktree" : "local",
canCreateWorktree,
showRefPicker: !supportsMultiplicity || isWorktree,
};
}
function backingLabel(t: TFunction, backing: "local" | "worktree"): string {
return backing === "worktree"
? t("newWorkspace.backing.worktree")
: t("newWorkspace.backing.local");
function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string {
return isolation === "worktree"
? t("newWorkspace.isolation.worktree")
: t("newWorkspace.isolation.local");
}
function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
@@ -781,7 +789,7 @@ async function createAndMergeWorkspace(input: {
async function createMultiplicityWorkspace(input: {
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
backing: "local" | "worktree";
isolation: "local" | "worktree";
project: HostProjectListItem;
selectedItem: PickerItem | null;
currentBranch: string | null;
@@ -795,7 +803,7 @@ async function createMultiplicityWorkspace(input: {
serverId: string;
createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
const isWorktree = input.backing === "worktree";
const isWorktree = input.isolation === "worktree";
const baseBranch = isWorktree
? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined)
: undefined;
@@ -1010,12 +1018,12 @@ export function NewWorkspaceScreen({
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const [backingPickerOpen, setBackingPickerOpen] = useState(false);
const [isolationPickerOpen, setIsolationPickerOpen] = useState(false);
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
const pickerAnchorRef = useRef<View>(null);
const projectPickerAnchorRef = useRef<View>(null);
const backingPickerAnchorRef = useRef<View>(null);
const isolationPickerAnchorRef = useRef<View>(null);
useEffect(() => {
const trimmed = pickerSearchQuery.trim();
@@ -1088,10 +1096,11 @@ export function NewWorkspaceScreen({
});
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
const { effectiveBacking, setBacking, canCreateWorktree, showRefPicker } = useWorkspaceBacking({
supportsMultiplicity: supportsWorkspaceMultiplicity,
selectedIsGit: checkoutStatusQuery.data?.isGit === true,
});
const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
useWorkspaceIsolation({
supportsMultiplicity: supportsWorkspaceMultiplicity,
selectedIsGit: checkoutStatusQuery.data?.isGit === true,
});
const branchSuggestionsQuery = useQuery({
queryKey: ["branch-suggestions", serverId, selectedSourceDirectory, debouncedPickerSearchQuery],
@@ -1219,31 +1228,31 @@ export function NewWorkspaceScreen({
setProjectPickerOpen(true);
}, []);
const openBackingPicker = useCallback(() => {
setBackingPickerOpen(true);
const openIsolationPicker = useCallback(() => {
setIsolationPickerOpen(true);
}, []);
const handleBackingPickerOpenChange = useCallback((nextOpen: boolean) => {
setBackingPickerOpen(nextOpen);
const handleIsolationPickerOpenChange = useCallback((nextOpen: boolean) => {
setIsolationPickerOpen(nextOpen);
}, []);
// "New worktree" is omitted entirely (not disabled) when the project isn't a
// git checkout, since worktree backing is impossible there.
const backingOptions = useMemo<ComboboxOptionType[]>(() => {
const localOption = { id: "local", label: backingLabel(t, "local") };
// git checkout, since worktree isolation is impossible there.
const isolationOptions = useMemo<ComboboxOptionType[]>(() => {
const localOption = { id: "local", label: isolationLabel(t, "local") };
if (!canCreateWorktree) return [localOption];
return [localOption, { id: "worktree", label: backingLabel(t, "worktree") }];
return [localOption, { id: "worktree", label: isolationLabel(t, "worktree") }];
}, [canCreateWorktree, t]);
const handleSelectBackingOption = useCallback(
const handleSelectIsolationOption = useCallback(
(id: string) => {
setBacking(id === "worktree" ? "worktree" : "local");
setBackingPickerOpen(false);
setIsolation(id === "worktree" ? "worktree" : "local");
setIsolationPickerOpen(false);
},
[setBacking],
[setIsolation],
);
const renderBackingOption = useCallback(
const renderIsolationOption = useCallback(
({
option,
selected,
@@ -1256,7 +1265,7 @@ export function NewWorkspaceScreen({
onPress: () => void;
}) => {
return (
<BackingOptionItem
<IsolationOptionItem
optionId={option.id}
label={option.label}
selected={selected}
@@ -1335,7 +1344,7 @@ export function NewWorkspaceScreen({
const normalizedWorkspace = supportsWorkspaceMultiplicity
? await createMultiplicityWorkspace({
client: withConnectedClient(),
backing: effectiveBacking,
isolation: effectiveIsolation,
project: selectedProject,
selectedItem,
currentBranch,
@@ -1360,7 +1369,7 @@ export function NewWorkspaceScreen({
buildCreateWorktreeInput,
createdWorkspace,
currentBranch,
effectiveBacking,
effectiveIsolation,
mergeWorkspaces,
selectedItem,
selectedProject,
@@ -1523,7 +1532,7 @@ export function NewWorkspaceScreen({
? t("newWorkspace.refPicker.searching")
: t("newWorkspace.refPicker.noMatchingRefs");
const backingTriggerLabel = backingLabel(t, effectiveBacking);
const isolationTriggerLabel = isolationLabel(t, effectiveIsolation);
const formStack = useMemo(() => {
const projectControl = (
@@ -1563,25 +1572,25 @@ export function NewWorkspaceScreen({
const isolationControl = canCreateWorktree ? (
<View>
<IsolationPickerTrigger
pickerAnchorRef={backingPickerAnchorRef}
onPress={openBackingPicker}
pickerAnchorRef={isolationPickerAnchorRef}
onPress={openIsolationPicker}
disabled={isPending}
badgePressableStyle={badgePressableStyle}
backing={effectiveBacking}
label={backingTriggerLabel}
isolation={effectiveIsolation}
label={isolationTriggerLabel}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={backingOptions}
value={effectiveBacking}
onSelect={handleSelectBackingOption}
title={t("newWorkspace.backing.label")}
open={backingPickerOpen}
onOpenChange={handleBackingPickerOpenChange}
options={isolationOptions}
value={effectiveIsolation}
onSelect={handleSelectIsolationOption}
title={t("newWorkspace.isolation.label")}
open={isolationPickerOpen}
onOpenChange={handleIsolationPickerOpenChange}
desktopPlacement="bottom-start"
anchorRef={backingPickerAnchorRef}
renderOption={renderBackingOption}
anchorRef={isolationPickerAnchorRef}
renderOption={renderIsolationOption}
/>
</View>
) : null;
@@ -1623,7 +1632,7 @@ export function NewWorkspaceScreen({
<View testID="new-workspace-ref-picker-row" style={styles.formStack}>
<FormRow>{projectControl}</FormRow>
{/* The Isolation row keeps its height for non-git projects so switching
projects never shifts the form; worktree backing is git-only, so a
projects never shifts the form; worktree isolation is git-only, so a
non-git project renders an invisible spacer matching the trigger
height exactly. */}
{isolationControl ? (
@@ -1632,7 +1641,7 @@ export function NewWorkspaceScreen({
<View style={styles.baseSpacer} />
)}
{/* The Base row keeps its height so toggling Isolation never shifts the
form; on Local backing it renders an invisible spacer with no label
form; on Local isolation it renders an invisible spacer with no label
or control, matching the trigger height exactly. */}
{baseControl ? <FormRow>{baseControl}</FormRow> : <View style={styles.baseSpacer} />}
</View>
@@ -1647,21 +1656,21 @@ export function NewWorkspaceScreen({
</View>
);
}, [
backingOptions,
backingPickerOpen,
backingTriggerLabel,
isolationOptions,
isolationPickerOpen,
isolationTriggerLabel,
badgePressableStyle,
canCreateWorktree,
effectiveBacking,
handleBackingPickerOpenChange,
effectiveIsolation,
handleIsolationPickerOpenChange,
handlePickerOpenChange,
handleProjectPickerOpenChange,
handleSelectBackingOption,
handleSelectIsolationOption,
handleSelectOption,
handleSelectProjectOption,
isCompact,
isPending,
openBackingPicker,
openIsolationPicker,
openPicker,
openProjectPicker,
options,
@@ -1671,7 +1680,7 @@ export function NewWorkspaceScreen({
projectPickerOpen,
projectPickerOptions,
projectTriggerLabel,
renderBackingOption,
renderIsolationOption,
renderPickerOption,
renderProjectOption,
selectedItem,