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. - **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** — 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". - **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`). - **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). - **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); 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, page: Page,
backing: "local" | "worktree", isolation: "local" | "worktree",
): Promise<void> { ): 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 expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click(); await trigger.click();
// "New worktree" is only listed once the checkout status query confirms the // "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 // selected project is a git repo, so wait for the option to appear before
// clicking it. // 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 expect(option).toBeVisible({ timeout: 30_000 });
await option.click(); await option.click();
} }
+3 -3
View File
@@ -131,9 +131,9 @@ test.describe("New workspace entry points", () => {
await nonGitOption.click(); await nonGitOption.click();
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName); 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. // 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. // Switching to the git project on the same screen reveals the Isolation row.
await trigger.click(); await trigger.click();
@@ -144,7 +144,7 @@ test.describe("New workspace entry points", () => {
await gitOption.click(); await gitOption.click();
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName); 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, timeout: 30_000,
}); });
} finally { } 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, selectBranchInPicker,
selectGitHubPrInPicker, selectGitHubPrInPicker,
selectPickerOptionByKeyboard, selectPickerOptionByKeyboard,
selectWorkspaceBacking, selectWorkspaceIsolation,
submitNewWorkspacePrompt, submitNewWorkspacePrompt,
} from "./helpers/new-workspace"; } from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
@@ -633,7 +633,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceBacking(page, "worktree"); await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page); await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev"); await selectBranchInPicker(page, "dev");
@@ -679,7 +679,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceBacking(page, "worktree"); await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page); await openBranchPicker(page);
await expectPickerOpen(page); await expectPickerOpen(page);
@@ -704,7 +704,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceBacking(page, "worktree"); await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page); await openBranchPicker(page);
await expectPickerOpen(page); await expectPickerOpen(page);
@@ -734,7 +734,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceBacking(page, "worktree"); await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page); await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number); await selectGitHubPrInPicker(page, pr.number);
@@ -6,7 +6,7 @@ import {
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected, expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer, openGlobalNewWorkspaceComposer,
selectWorkspaceBacking, selectWorkspaceIsolation,
submitNewWorkspaceEmpty, submitNewWorkspaceEmpty,
submitNewWorkspacePrompt, submitNewWorkspacePrompt,
} from "./helpers/new-workspace"; } from "./helpers/new-workspace";
@@ -261,7 +261,7 @@ test.describe("Workspace model regressions", () => {
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page); await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local"); await selectWorkspaceIsolation(page, "local");
await submitNewWorkspacePrompt(page, "Fix login bug"); await submitNewWorkspacePrompt(page, "Fix login bug");
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -346,7 +346,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page); await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local"); await selectWorkspaceIsolation(page, "local");
await submitNewWorkspaceEmpty(page); await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -462,7 +462,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page); await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local"); await selectWorkspaceIsolation(page, "local");
await submitNewWorkspaceEmpty(page); await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
+13 -13
View File
@@ -6,7 +6,7 @@ import {
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer, openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject, selectNewWorkspaceProject,
selectWorkspaceBacking, selectWorkspaceIsolation,
submitNewWorkspaceEmpty, submitNewWorkspaceEmpty,
} from "./helpers/new-workspace"; } from "./helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; 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 { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B reshape: a workspace is the unit, its backing directory (local // Model B reshape: a workspace is the unit, its isolation (local checkout or
// checkout or worktree) is a CHOICE at creation, and creation NEVER dedupes by // worktree) is a CHOICE at creation, and creation NEVER dedupes by
// directory. These specs drive the real creation UI (workspace-create-* test // directory. These specs drive the real creation UI (workspace-create-* test
// IDs) to prove a single directory can back any number of workspaces. // IDs) to prove a single directory can back any number of workspaces.
@@ -39,16 +39,16 @@ async function createWorkspaceViaUi(
input: { input: {
project: { projectKey: string; projectDisplayName: string }; project: { projectKey: string; projectDisplayName: string };
// null when the project has no git checkout: there is no Isolation control to // null when the project has no git checkout: there is no Isolation control to
// touch, the backing is implicitly local. // touch, the isolation is implicitly local.
backing: "local" | "worktree" | null; isolation: "local" | "worktree" | null;
previousWorkspaceId: string; previousWorkspaceId: string;
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>; client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
}, },
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> { ): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
await openGlobalNewWorkspaceComposer(page); await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, input.project); await selectNewWorkspaceProject(page, input.project);
if (input.backing !== null) { if (input.isolation !== null) {
await selectWorkspaceBacking(page, input.backing); await selectWorkspaceIsolation(page, input.isolation);
} }
await submitNewWorkspaceEmpty(page); await submitNewWorkspaceEmpty(page);
@@ -96,7 +96,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, { const second = await createWorkspaceViaUi(page, {
project, project,
backing: "local", isolation: "local",
previousWorkspaceId: seeded.workspaceId, previousWorkspaceId: seeded.workspaceId,
client, 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, page,
}) => { }) => {
const seeded: SeededWorkspace = await seedWorkspace({ const seeded: SeededWorkspace = await seedWorkspace({
@@ -148,13 +148,13 @@ test.describe("Workspace multiplicity creation flow", () => {
const worktree = await createWorkspaceViaUi(page, { const worktree = await createWorkspaceViaUi(page, {
project, project,
backing: "worktree", isolation: "worktree",
previousWorkspaceId: seeded.workspaceId, previousWorkspaceId: seeded.workspaceId,
client, client,
}); });
// The worktree row appears, pointing at a directory distinct from the // The worktree row appears, pointing at a directory distinct from the
// backing checkout. // local checkout.
const worktreeRow = page.getByTestId(workspaceRowTestId(worktree.workspaceId)); const worktreeRow = page.getByTestId(workspaceRowTestId(worktree.workspaceId));
await expect(worktreeRow).toBeVisible({ timeout: 30_000 }); await expect(worktreeRow).toBeVisible({ timeout: 30_000 });
expect(worktree.workspaceId).not.toBe(seeded.workspaceId); expect(worktree.workspaceId).not.toBe(seeded.workspaceId);
@@ -199,8 +199,8 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, { const second = await createWorkspaceViaUi(page, {
project, project,
// Non-git project: no Isolation control, backing is implicitly local. // Non-git project: no Isolation control, isolation is implicitly local.
backing: null, isolation: null,
previousWorkspaceId: seeded.workspaceId, previousWorkspaceId: seeded.workspaceId,
client, client,
}); });
@@ -84,4 +84,27 @@ describe("create agent preferences", () => {
it("loads invalid stored preferences as empty preferences", () => { it("loads invalid stored preferences as empty preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({}); 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(), .optional(),
isolation: z.enum(["local", "worktree"]).optional(),
}); });
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>; export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
+1 -1
View File
@@ -833,7 +833,7 @@ export const ar: TranslationResources = {
newWorkspace: { newWorkspace: {
title: "مساحة عمل جديدة", title: "مساحة عمل جديدة",
create: "يخلق", create: "يخلق",
backing: { isolation: {
local: "محلي", local: "محلي",
worktree: "شجرة عمل جديدة", worktree: "شجرة عمل جديدة",
label: "العزل", label: "العزل",
+1 -1
View File
@@ -839,7 +839,7 @@ export const en = {
newWorkspace: { newWorkspace: {
title: "New workspace", title: "New workspace",
create: "Create", create: "Create",
backing: { isolation: {
local: "Local", local: "Local",
worktree: "New worktree", worktree: "New worktree",
label: "Isolation", label: "Isolation",
+1 -1
View File
@@ -859,7 +859,7 @@ export const es: TranslationResources = {
newWorkspace: { newWorkspace: {
title: "Nuevo espacio de trabajo", title: "Nuevo espacio de trabajo",
create: "Crear", create: "Crear",
backing: { isolation: {
local: "Local", local: "Local",
worktree: "Nuevo worktree", worktree: "Nuevo worktree",
label: "Aislamiento", label: "Aislamiento",
+1 -1
View File
@@ -858,7 +858,7 @@ export const fr: TranslationResources = {
newWorkspace: { newWorkspace: {
title: "Nouvel espace de travail", title: "Nouvel espace de travail",
create: "Créer", create: "Créer",
backing: { isolation: {
local: "Local", local: "Local",
worktree: "Nouveau worktree", worktree: "Nouveau worktree",
label: "Isolation", label: "Isolation",
+1 -1
View File
@@ -851,7 +851,7 @@ export const ru: TranslationResources = {
newWorkspace: { newWorkspace: {
title: "Новое рабочее пространство", title: "Новое рабочее пространство",
create: "Создавать", create: "Создавать",
backing: { isolation: {
local: "Локально", local: "Локально",
worktree: "Новый worktree", worktree: "Новый worktree",
label: "Изоляция", label: "Изоляция",
+1 -1
View File
@@ -824,7 +824,7 @@ export const zhCN: TranslationResources = {
newWorkspace: { newWorkspace: {
title: "新建 workspace", title: "新建 workspace",
create: "创建", create: "创建",
backing: { isolation: {
local: "本地", local: "本地",
worktree: "新建 worktree", worktree: "新建 worktree",
label: "隔离", label: "隔离",
@@ -37,6 +37,7 @@ import { useDraftStore } from "@/stores/draft-store";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
@@ -375,7 +376,7 @@ function PickerOptionItem({
); );
} }
function BackingOptionItem({ function IsolationOptionItem({
optionId, optionId,
label, label,
selected, selected,
@@ -408,7 +409,7 @@ function BackingOptionItem({
); );
return ( return (
<ComboboxItem <ComboboxItem
testID={`workspace-create-backing-${optionId}`} testID={`workspace-create-isolation-${optionId}`}
label={label} label={label}
selected={selected} selected={selected}
active={active} active={active}
@@ -636,7 +637,7 @@ function IsolationPickerTrigger({
onPress, onPress,
disabled, disabled,
badgePressableStyle, badgePressableStyle,
backing, isolation,
label, label,
iconColor, iconColor,
iconSize, iconSize,
@@ -645,7 +646,7 @@ function IsolationPickerTrigger({
onPress: () => void; onPress: () => void;
disabled: boolean; disabled: boolean;
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"]; badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
backing: "local" | "worktree"; isolation: "local" | "worktree";
label: string; label: string;
iconColor: string; iconColor: string;
iconSize: number; iconSize: number;
@@ -653,7 +654,7 @@ function IsolationPickerTrigger({
return ( return (
<ComboboxTrigger <ComboboxTrigger
ref={pickerAnchorRef} ref={pickerAnchorRef}
testID="workspace-create-backing-trigger" testID="workspace-create-isolation-trigger"
onPress={onPress} onPress={onPress}
disabled={disabled} disabled={disabled}
style={badgePressableStyle} style={badgePressableStyle}
@@ -661,7 +662,7 @@ function IsolationPickerTrigger({
accessibilityLabel="Workspace isolation" accessibilityLabel="Workspace isolation"
> >
<View style={styles.badgeIconBox}> <View style={styles.badgeIconBox}>
{backing === "worktree" ? ( {isolation === "worktree" ? (
<GitBranch size={iconSize} color={iconColor} /> <GitBranch size={iconSize} color={iconColor} />
) : ( ) : (
<Folder 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>; return <View style={styles.row}>{children}</View>;
} }
interface WorkspaceBackingState { interface WorkspaceIsolationState {
backing: "local" | "worktree"; isolation: "local" | "worktree";
setBacking: (value: "local" | "worktree") => void; setIsolation: (value: "local" | "worktree") => void;
effectiveBacking: "local" | "worktree"; effectiveIsolation: "local" | "worktree";
canCreateWorktree: boolean; canCreateWorktree: boolean;
showRefPicker: 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 // falls back to local whenever the selected directory isn't git so the flow
// never submits an impossible request. // never submits an impossible request.
function useWorkspaceBacking(input: { function useWorkspaceIsolation(input: {
supportsMultiplicity: boolean; supportsMultiplicity: boolean;
selectedIsGit: boolean; selectedIsGit: boolean;
}): WorkspaceBackingState { }): WorkspaceIsolationState {
const { supportsMultiplicity, selectedIsGit } = input; 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 canCreateWorktree = supportsMultiplicity && selectedIsGit;
const isWorktree = backing === "worktree" && canCreateWorktree; const isWorktree = isolation === "worktree" && canCreateWorktree;
useEffect(() => { const setIsolation = useCallback(
if (backing === "worktree" && !canCreateWorktree) { (value: "local" | "worktree") => {
setBacking("local"); setManualIsolation(value);
} void updatePreferences({ isolation: value });
}, [backing, canCreateWorktree]); },
[updatePreferences],
);
return { return {
backing, isolation,
setBacking, setIsolation,
effectiveBacking: isWorktree ? "worktree" : "local", effectiveIsolation: isWorktree ? "worktree" : "local",
canCreateWorktree, canCreateWorktree,
showRefPicker: !supportsMultiplicity || isWorktree, showRefPicker: !supportsMultiplicity || isWorktree,
}; };
} }
function backingLabel(t: TFunction, backing: "local" | "worktree"): string { function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string {
return backing === "worktree" return isolation === "worktree"
? t("newWorkspace.backing.worktree") ? t("newWorkspace.isolation.worktree")
: t("newWorkspace.backing.local"); : t("newWorkspace.isolation.local");
} }
function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
@@ -781,7 +789,7 @@ async function createAndMergeWorkspace(input: {
async function createMultiplicityWorkspace(input: { async function createMultiplicityWorkspace(input: {
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>; client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
backing: "local" | "worktree"; isolation: "local" | "worktree";
project: HostProjectListItem; project: HostProjectListItem;
selectedItem: PickerItem | null; selectedItem: PickerItem | null;
currentBranch: string | null; currentBranch: string | null;
@@ -795,7 +803,7 @@ async function createMultiplicityWorkspace(input: {
serverId: string; serverId: string;
createFailedMessage: string; createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> { }): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
const isWorktree = input.backing === "worktree"; const isWorktree = input.isolation === "worktree";
const baseBranch = isWorktree const baseBranch = isWorktree
? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined) ? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined)
: undefined; : undefined;
@@ -1010,12 +1018,12 @@ export function NewWorkspaceScreen({
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null); const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const [backingPickerOpen, setBackingPickerOpen] = useState(false); const [isolationPickerOpen, setIsolationPickerOpen] = useState(false);
const [pickerSearchQuery, setPickerSearchQuery] = useState(""); const [pickerSearchQuery, setPickerSearchQuery] = useState("");
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
const pickerAnchorRef = useRef<View>(null); const pickerAnchorRef = useRef<View>(null);
const projectPickerAnchorRef = useRef<View>(null); const projectPickerAnchorRef = useRef<View>(null);
const backingPickerAnchorRef = useRef<View>(null); const isolationPickerAnchorRef = useRef<View>(null);
useEffect(() => { useEffect(() => {
const trimmed = pickerSearchQuery.trim(); const trimmed = pickerSearchQuery.trim();
@@ -1088,10 +1096,11 @@ export function NewWorkspaceScreen({
}); });
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
const { effectiveBacking, setBacking, canCreateWorktree, showRefPicker } = useWorkspaceBacking({ const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
supportsMultiplicity: supportsWorkspaceMultiplicity, useWorkspaceIsolation({
selectedIsGit: checkoutStatusQuery.data?.isGit === true, supportsMultiplicity: supportsWorkspaceMultiplicity,
}); selectedIsGit: checkoutStatusQuery.data?.isGit === true,
});
const branchSuggestionsQuery = useQuery({ const branchSuggestionsQuery = useQuery({
queryKey: ["branch-suggestions", serverId, selectedSourceDirectory, debouncedPickerSearchQuery], queryKey: ["branch-suggestions", serverId, selectedSourceDirectory, debouncedPickerSearchQuery],
@@ -1219,31 +1228,31 @@ export function NewWorkspaceScreen({
setProjectPickerOpen(true); setProjectPickerOpen(true);
}, []); }, []);
const openBackingPicker = useCallback(() => { const openIsolationPicker = useCallback(() => {
setBackingPickerOpen(true); setIsolationPickerOpen(true);
}, []); }, []);
const handleBackingPickerOpenChange = useCallback((nextOpen: boolean) => { const handleIsolationPickerOpenChange = useCallback((nextOpen: boolean) => {
setBackingPickerOpen(nextOpen); setIsolationPickerOpen(nextOpen);
}, []); }, []);
// "New worktree" is omitted entirely (not disabled) when the project isn't a // "New worktree" is omitted entirely (not disabled) when the project isn't a
// git checkout, since worktree backing is impossible there. // git checkout, since worktree isolation is impossible there.
const backingOptions = useMemo<ComboboxOptionType[]>(() => { const isolationOptions = useMemo<ComboboxOptionType[]>(() => {
const localOption = { id: "local", label: backingLabel(t, "local") }; const localOption = { id: "local", label: isolationLabel(t, "local") };
if (!canCreateWorktree) return [localOption]; if (!canCreateWorktree) return [localOption];
return [localOption, { id: "worktree", label: backingLabel(t, "worktree") }]; return [localOption, { id: "worktree", label: isolationLabel(t, "worktree") }];
}, [canCreateWorktree, t]); }, [canCreateWorktree, t]);
const handleSelectBackingOption = useCallback( const handleSelectIsolationOption = useCallback(
(id: string) => { (id: string) => {
setBacking(id === "worktree" ? "worktree" : "local"); setIsolation(id === "worktree" ? "worktree" : "local");
setBackingPickerOpen(false); setIsolationPickerOpen(false);
}, },
[setBacking], [setIsolation],
); );
const renderBackingOption = useCallback( const renderIsolationOption = useCallback(
({ ({
option, option,
selected, selected,
@@ -1256,7 +1265,7 @@ export function NewWorkspaceScreen({
onPress: () => void; onPress: () => void;
}) => { }) => {
return ( return (
<BackingOptionItem <IsolationOptionItem
optionId={option.id} optionId={option.id}
label={option.label} label={option.label}
selected={selected} selected={selected}
@@ -1335,7 +1344,7 @@ export function NewWorkspaceScreen({
const normalizedWorkspace = supportsWorkspaceMultiplicity const normalizedWorkspace = supportsWorkspaceMultiplicity
? await createMultiplicityWorkspace({ ? await createMultiplicityWorkspace({
client: withConnectedClient(), client: withConnectedClient(),
backing: effectiveBacking, isolation: effectiveIsolation,
project: selectedProject, project: selectedProject,
selectedItem, selectedItem,
currentBranch, currentBranch,
@@ -1360,7 +1369,7 @@ export function NewWorkspaceScreen({
buildCreateWorktreeInput, buildCreateWorktreeInput,
createdWorkspace, createdWorkspace,
currentBranch, currentBranch,
effectiveBacking, effectiveIsolation,
mergeWorkspaces, mergeWorkspaces,
selectedItem, selectedItem,
selectedProject, selectedProject,
@@ -1523,7 +1532,7 @@ export function NewWorkspaceScreen({
? t("newWorkspace.refPicker.searching") ? t("newWorkspace.refPicker.searching")
: t("newWorkspace.refPicker.noMatchingRefs"); : t("newWorkspace.refPicker.noMatchingRefs");
const backingTriggerLabel = backingLabel(t, effectiveBacking); const isolationTriggerLabel = isolationLabel(t, effectiveIsolation);
const formStack = useMemo(() => { const formStack = useMemo(() => {
const projectControl = ( const projectControl = (
@@ -1563,25 +1572,25 @@ export function NewWorkspaceScreen({
const isolationControl = canCreateWorktree ? ( const isolationControl = canCreateWorktree ? (
<View> <View>
<IsolationPickerTrigger <IsolationPickerTrigger
pickerAnchorRef={backingPickerAnchorRef} pickerAnchorRef={isolationPickerAnchorRef}
onPress={openBackingPicker} onPress={openIsolationPicker}
disabled={isPending} disabled={isPending}
badgePressableStyle={badgePressableStyle} badgePressableStyle={badgePressableStyle}
backing={effectiveBacking} isolation={effectiveIsolation}
label={backingTriggerLabel} label={isolationTriggerLabel}
iconColor={theme.colors.foregroundMuted} iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm} iconSize={theme.iconSize.sm}
/> />
<Combobox <Combobox
options={backingOptions} options={isolationOptions}
value={effectiveBacking} value={effectiveIsolation}
onSelect={handleSelectBackingOption} onSelect={handleSelectIsolationOption}
title={t("newWorkspace.backing.label")} title={t("newWorkspace.isolation.label")}
open={backingPickerOpen} open={isolationPickerOpen}
onOpenChange={handleBackingPickerOpenChange} onOpenChange={handleIsolationPickerOpenChange}
desktopPlacement="bottom-start" desktopPlacement="bottom-start"
anchorRef={backingPickerAnchorRef} anchorRef={isolationPickerAnchorRef}
renderOption={renderBackingOption} renderOption={renderIsolationOption}
/> />
</View> </View>
) : null; ) : null;
@@ -1623,7 +1632,7 @@ export function NewWorkspaceScreen({
<View testID="new-workspace-ref-picker-row" style={styles.formStack}> <View testID="new-workspace-ref-picker-row" style={styles.formStack}>
<FormRow>{projectControl}</FormRow> <FormRow>{projectControl}</FormRow>
{/* The Isolation row keeps its height for non-git projects so switching {/* 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 non-git project renders an invisible spacer matching the trigger
height exactly. */} height exactly. */}
{isolationControl ? ( {isolationControl ? (
@@ -1632,7 +1641,7 @@ export function NewWorkspaceScreen({
<View style={styles.baseSpacer} /> <View style={styles.baseSpacer} />
)} )}
{/* The Base row keeps its height so toggling Isolation never shifts the {/* 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. */} or control, matching the trigger height exactly. */}
{baseControl ? <FormRow>{baseControl}</FormRow> : <View style={styles.baseSpacer} />} {baseControl ? <FormRow>{baseControl}</FormRow> : <View style={styles.baseSpacer} />}
</View> </View>
@@ -1647,21 +1656,21 @@ export function NewWorkspaceScreen({
</View> </View>
); );
}, [ }, [
backingOptions, isolationOptions,
backingPickerOpen, isolationPickerOpen,
backingTriggerLabel, isolationTriggerLabel,
badgePressableStyle, badgePressableStyle,
canCreateWorktree, canCreateWorktree,
effectiveBacking, effectiveIsolation,
handleBackingPickerOpenChange, handleIsolationPickerOpenChange,
handlePickerOpenChange, handlePickerOpenChange,
handleProjectPickerOpenChange, handleProjectPickerOpenChange,
handleSelectBackingOption, handleSelectIsolationOption,
handleSelectOption, handleSelectOption,
handleSelectProjectOption, handleSelectProjectOption,
isCompact, isCompact,
isPending, isPending,
openBackingPicker, openIsolationPicker,
openPicker, openPicker,
openProjectPicker, openProjectPicker,
options, options,
@@ -1671,7 +1680,7 @@ export function NewWorkspaceScreen({
projectPickerOpen, projectPickerOpen,
projectPickerOptions, projectPickerOptions,
projectTriggerLabel, projectTriggerLabel,
renderBackingOption, renderIsolationOption,
renderPickerOption, renderPickerOption,
renderProjectOption, renderProjectOption,
selectedItem, selectedItem,