fix(ui): preserve manually entered worktree base (#105351)

This commit is contained in:
Peter Steinberger
2026-07-12 12:59:47 +01:00
committed by GitHub
parent 96aff2787d
commit 879b66ab46
2 changed files with 134 additions and 79 deletions
+126 -78
View File
@@ -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<Locator> {
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",
+8 -1
View File
@@ -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();
}}
/>