diff --git a/docs/glossary.md b/docs/glossary.md index 6a9513e8d..5a9d14b99 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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). diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 31d0f9f37..e7cc378f8 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -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 { - 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 { + 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(); } diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index 33049a632..eaf033050 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -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 { diff --git a/packages/app/e2e/new-workspace-isolation-memory.spec.ts b/packages/app/e2e/new-workspace-isolation-memory.spec.ts new file mode 100644 index 000000000..6e90589fd --- /dev/null +++ b/packages/app/e2e/new-workspace-isolation-memory.spec.ts @@ -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>; + const localWorkspaceIds = new Set(); + const createdWorktreeDirectories = new Set(); + + 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(); + } + }); +}); diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index 0fc3eb56b..37e3c8694 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -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); diff --git a/packages/app/e2e/workspace-model-regressions.spec.ts b/packages/app/e2e/workspace-model-regressions.spec.ts index 9546f7527..aaac489e6 100644 --- a/packages/app/e2e/workspace-model-regressions.spec.ts +++ b/packages/app/e2e/workspace-model-regressions.spec.ts @@ -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, { diff --git a/packages/app/e2e/workspace-multiplicity.spec.ts b/packages/app/e2e/workspace-multiplicity.spec.ts index 53604a599..1e5066cbe 100644 --- a/packages/app/e2e/workspace-multiplicity.spec.ts +++ b/packages/app/e2e/workspace-multiplicity.spec.ts @@ -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>; }, ): 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, }); diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index 70368f718..96ebfd592 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -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({}); + }); }); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 24405acb6..a216570f0 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -33,6 +33,7 @@ const formPreferencesSchema = z.object({ }), ) .optional(), + isolation: z.enum(["local", "worktree"]).optional(), }); export type ProviderPreferences = z.infer; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index cdc1447bc..2c5daced0 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -833,7 +833,7 @@ export const ar: TranslationResources = { newWorkspace: { title: "مساحة عمل جديدة", create: "يخلق", - backing: { + isolation: { local: "محلي", worktree: "شجرة عمل جديدة", label: "العزل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1d8f0f01d..0aa2c4b67 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -839,7 +839,7 @@ export const en = { newWorkspace: { title: "New workspace", create: "Create", - backing: { + isolation: { local: "Local", worktree: "New worktree", label: "Isolation", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7ac3154e6..a32cba7d1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index fdf895ce8..3273037b2 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 8f2ad0054..1af3ba116 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -851,7 +851,7 @@ export const ru: TranslationResources = { newWorkspace: { title: "Новое рабочее пространство", create: "Создавать", - backing: { + isolation: { local: "Локально", worktree: "Новый worktree", label: "Изоляция", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c650185d6..497e0d235 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -824,7 +824,7 @@ export const zhCN: TranslationResources = { newWorkspace: { title: "新建 workspace", create: "创建", - backing: { + isolation: { local: "本地", worktree: "新建 worktree", label: "隔离", diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index d34b7c86e..2d51d8a03 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -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 ( void; disabled: boolean; badgePressableStyle: React.ComponentProps["style"]; - backing: "local" | "worktree"; + isolation: "local" | "worktree"; label: string; iconColor: string; iconSize: number; @@ -653,7 +654,7 @@ function IsolationPickerTrigger({ return ( - {backing === "worktree" ? ( + {isolation === "worktree" ? ( ) : ( @@ -680,45 +681,52 @@ function FormRow({ children }: { children: React.ReactNode }) { return {children}; } -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>; - 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> { - 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(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(null); const projectPickerAnchorRef = useRef(null); - const backingPickerAnchorRef = useRef(null); + const isolationPickerAnchorRef = useRef(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(() => { - const localOption = { id: "local", label: backingLabel(t, "local") }; + // git checkout, since worktree isolation is impossible there. + const isolationOptions = useMemo(() => { + 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 ( - { const projectControl = ( @@ -1563,25 +1572,25 @@ export function NewWorkspaceScreen({ const isolationControl = canCreateWorktree ? ( ) : null; @@ -1623,7 +1632,7 @@ export function NewWorkspaceScreen({ {projectControl} {/* 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({ )} {/* 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 ? {baseControl} : } @@ -1647,21 +1656,21 @@ export function NewWorkspaceScreen({ ); }, [ - 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,