diff --git a/ui/src/e2e/new-session-page.e2e.test.ts b/ui/src/e2e/new-session-page.e2e.test.ts index 1f24fdf851cb..c2779de9f785 100644 --- a/ui/src/e2e/new-session-page.e2e.test.ts +++ b/ui/src/e2e/new-session-page.e2e.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover the full-page new-session draft and its folder browser // against a mocked Gateway: sidebar entry, fs.listDir browsing, and the final // sessions.create payload. -import { chromium, type Browser } from "playwright"; +import { chromium, type Browser, type Locator, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { canRunPlaywrightChromium, @@ -9,6 +9,7 @@ import { resolvePlaywrightChromiumExecutablePath, startControlUiE2eServer, type ControlUiE2eServer, + type MockGatewayControls, } from "../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); @@ -24,6 +25,96 @@ const NODE_HOME = "/Users/peter"; const NODE_PICKED = "/Users/peter/Projects"; const NODE_UNC = "\\\\server\\share\\repo"; +function installRepositorySwitchGateway(page: Page, sessionKey: string) { + return installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: SOURCE_REPO, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "fs.listDir": { + cases: [ + { + match: { path: SOURCE_REPO }, + response: { + path: SOURCE_REPO, + parent: "/tmp", + home: "/home/peter", + entries: [], + }, + }, + ], + }, + "worktrees.branches": { + cases: [ + { + match: { repoRoot: SOURCE_REPO }, + response: { + branches: [{ kind: "local", name: "alpha" }], + headBranch: "alpha", + repoRoot: SOURCE_REPO, + }, + }, + { + match: { repoRoot: TARGET_REPO }, + response: { + branches: [ + { kind: "local", name: "main" }, + { kind: "local", name: "feature-choice" }, + ], + headBranch: "main", + repoRoot: TARGET_REPO, + }, + }, + ], + }, + "sessions.create": { key: sessionKey }, + }, + }); +} + +async function deferTargetRepositorySelection( + page: Page, + gateway: MockGatewayControls, +): Promise { + await page.goto(`${server.baseUrl}new`); + await gateway.waitForRequest("worktrees.branches"); + + const whereSelect = page.locator( + ".new-session-page__select:not(.new-session-page__select--folder)", + ); + await whereSelect.locator("summary").click(); + await page.getByRole("menuitemradio", { name: "Worktree" }).click(); + const baseInput = page.getByLabel("Base branch"); + await expect.poll(() => baseInput.inputValue()).toBe("alpha"); + const requestsBeforeSwitch = (await gateway.getRequests("worktrees.branches")).length; + + await gateway.deferNext("worktrees.branches"); + const folderSelect = page.locator(".new-session-page__select--folder"); + await folderSelect.locator("summary").click(); + await page + .locator(".new-session-page__browser-list") + .getByRole("button", { name: "Gateway" }) + .click(); + await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); + await page.getByRole("button", { name: "Use this folder" }).click(); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(requestsBeforeSwitch + 1); + return baseInput; +} + let browser: Browser; let server: ControlUiE2eServer; @@ -183,85 +274,10 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { viewport: { height: 900, width: 1280 }, }); const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: SOURCE_REPO, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "fs.listDir": { - cases: [ - { - match: { path: SOURCE_REPO }, - response: { - path: SOURCE_REPO, - parent: "/tmp", - home: "/home/peter", - entries: [], - }, - }, - ], - }, - "worktrees.branches": { - cases: [ - { - match: { repoRoot: SOURCE_REPO }, - response: { - branches: [{ kind: "local", name: "alpha" }], - headBranch: "alpha", - repoRoot: SOURCE_REPO, - }, - }, - { - match: { repoRoot: TARGET_REPO }, - response: { - branches: [{ kind: "local", name: "main" }], - headBranch: "main", - repoRoot: TARGET_REPO, - }, - }, - ], - }, - "sessions.create": { key: "agent:main:repo-switch" }, - }, - }); + const gateway = await installRepositorySwitchGateway(page, "agent:main:repo-switch"); try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("worktrees.branches"); - - const whereSelect = page.locator( - ".new-session-page__select:not(.new-session-page__select--folder)", - ); - await whereSelect.locator("summary").click(); - await page.getByRole("menuitemradio", { name: "Worktree" }).click(); - const baseInput = page.getByLabel("Base branch"); - await expect.poll(() => baseInput.inputValue()).toBe("alpha"); - const branchRequestsBeforeSwitch = (await gateway.getRequests("worktrees.branches")).length; - - await gateway.deferNext("worktrees.branches"); - const folderSelect = page.locator(".new-session-page__select--folder"); - await folderSelect.locator("summary").click(); - await page - .locator(".new-session-page__browser-list") - .getByRole("button", { name: "Gateway" }) - .click(); - await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); - await page.getByRole("button", { name: "Use this folder" }).click(); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequestsBeforeSwitch + 1); + const baseInput = await deferTargetRepositorySelection(page, gateway); expect(await baseInput.inputValue()).toBe(""); expect(await baseInput.getAttribute("placeholder")).toBe("Loading…"); @@ -280,6 +296,38 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { } }); + it("preserves a manually entered worktree base when branch discovery resolves", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installRepositorySwitchGateway(page, "agent:main:manual-base"); + + try { + const baseInput = await deferTargetRepositorySelection(page, gateway); + await page + .locator(".new-session-page__select:not(.new-session-page__select--folder) summary") + .click(); + await baseInput.fill("feature-choice"); + await gateway.resolveDeferred("worktrees.branches"); + await expect.poll(() => baseInput.getAttribute("placeholder")).not.toBe("Loading…"); + expect(await baseInput.inputValue()).toBe("feature-choice"); + + await page.locator(".new-session-page__message").fill("use my selected base"); + await page.getByRole("button", { name: "Start session" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + cwd: TARGET_REPO, + worktree: true, + worktreeBaseRef: "feature-choice", + }); + } finally { + await context.close(); + } + }); + it("keeps a rejected first message visible and retryable after reload", async () => { const context = await browser.newContext({ locale: "en-US", diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index c0359b2983ef..bf86bfce45c4 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -85,6 +85,7 @@ class NewSessionPage extends OpenClawLightDomElement { private openedFor: string | null = null; private agentsHydrated = false; private branchesRequestToken = 0; + private baseRefEditGeneration = 0; private browserRequestToken = 0; // Re-render when agents/sessions hydrate so the hero identity and the @@ -321,6 +322,7 @@ class NewSessionPage extends OpenClawLightDomElement { // Branch data belongs to one repository selection. Clear it before any // exit or request so a previous repo's ref can never reach sessions.create. const requestId = ++this.branchesRequestToken; + const baseRefEditGeneration = this.baseRefEditGeneration; this.branches = null; this.branchesLoading = false; this.baseRef = ""; @@ -346,7 +348,11 @@ class NewSessionPage extends OpenClawLightDomElement { return; } this.branches = result ? { ...result, repoRoot } : null; - this.baseRef = result?.defaultBranch ?? result?.headBranch ?? ""; + // Discovery supplies a default only while the field is untouched; + // a user edit made during the request remains authoritative. + if (baseRefEditGeneration === this.baseRefEditGeneration) { + this.baseRef = result?.defaultBranch ?? result?.headBranch ?? ""; + } }) .catch(() => { if (requestId === this.branchesRequestToken) { @@ -902,6 +908,7 @@ class NewSessionPage extends OpenClawLightDomElement { : (branches?.defaultBranch ?? t("newSession.baseBranch"))} .value=${this.baseRef} @input=${(event: Event) => { + this.baseRefEditGeneration += 1; this.baseRef = (event.target as HTMLInputElement).value.trim(); }} />