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:
+2
-1
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -833,7 +833,7 @@ export const ar: TranslationResources = {
|
||||
newWorkspace: {
|
||||
title: "مساحة عمل جديدة",
|
||||
create: "يخلق",
|
||||
backing: {
|
||||
isolation: {
|
||||
local: "محلي",
|
||||
worktree: "شجرة عمل جديدة",
|
||||
label: "العزل",
|
||||
|
||||
@@ -839,7 +839,7 @@ export const en = {
|
||||
newWorkspace: {
|
||||
title: "New workspace",
|
||||
create: "Create",
|
||||
backing: {
|
||||
isolation: {
|
||||
local: "Local",
|
||||
worktree: "New worktree",
|
||||
label: "Isolation",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -851,7 +851,7 @@ export const ru: TranslationResources = {
|
||||
newWorkspace: {
|
||||
title: "Новое рабочее пространство",
|
||||
create: "Создавать",
|
||||
backing: {
|
||||
isolation: {
|
||||
local: "Локально",
|
||||
worktree: "Новый worktree",
|
||||
label: "Изоляция",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user