feat(ui): split new session place picker (#122938)

* feat(ui): split new session place picker

* test(ui): cover three-chip session picker
This commit is contained in:
Peter Steinberger
2026-08-12 20:09:27 -07:00
committed by GitHub
parent ae20e2c163
commit 495f295f25
33 changed files with 2373 additions and 1656 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ describeControlUiE2e("Control UI live device scope upgrade", () => {
await navigation;
await page.getByRole("button", { name: "Request admin" }).waitFor();
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-project-trigger").click();
const browse = page.getByRole("button", { name: "Browse folders" });
await expect.poll(() => browse.isDisabled()).toBe(true);
await browse.focus();
@@ -297,15 +297,15 @@ suite.define(() => {
await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("Claude Code");
await expect.poll(() => page.locator(".new-session-page__start-split").count()).toBe(1);
await page.locator("#new-session-place-trigger").click();
const placePopover = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-detail-trigger").click();
const placePopover = page.locator("wa-popover.new-session-page__detail-popover");
const worktreeButton = placePopover.getByRole("button", { name: "Worktree" });
await worktreeButton.waitFor({ state: "visible" });
const initialBranchRequestCount = (await gateway.getRequests("worktrees.branches")).length;
await worktreeButton.click();
await expect.poll(() => placePopover.getByLabel("Base branch").inputValue()).toBe("main");
await placePopover.getByLabel("Worktree name").fill("terminal-task");
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-detail-trigger").click();
await page.locator(".new-session-page__message").fill(" inspect the checkout ");
if (captureCliAgentsProof) {
@@ -947,7 +947,7 @@ suite.define(() => {
await expect.poll(() => message.inputValue()).toBe("keep my selected agent");
await pollLocatorText(page.getByRole("heading").first()).toContain("Research");
await pollLocatorText(
page.locator("#new-session-place-trigger .new-session-page__trigger-label"),
page.locator("#new-session-project-trigger .new-session-page__trigger-label"),
).toBe("research-next");
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -957,8 +957,8 @@ suite.define(() => {
includeRepositoryStatus: true,
});
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeTrigger = page.locator("#new-session-place-trigger");
const placeSelect = page.locator("wa-popover.new-session-page__detail-popover");
const placeTrigger = page.locator("#new-session-detail-trigger");
await placeTrigger.click();
const worktreeItem = placeSelect.getByRole("button", { name: "Worktree" });
await worktreeItem.click();
@@ -54,9 +54,9 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-where-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.locator("wa-popover.new-session-page__where-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.locator(".new-session-page__message").fill("surface the failed startup");
@@ -126,12 +126,16 @@ suite.define(() => {
})),
).toEqual({ hasSubtleCrypto: true, isSecureContext: true });
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
const place = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-where-trigger").click();
const place = page.locator("wa-popover.new-session-page__where-popover");
await place.getByRole("button", { name: "Cloud · aws" }).click();
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-where-trigger");
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
const detailTrigger = page.locator("#new-session-detail-trigger");
await detailTrigger.click();
const detail = page.locator("wa-popover.new-session-page__detail-popover");
expect(await detail.getByRole("button", { name: "Worktree" }).isDisabled()).toBe(true);
await detail.getByText("Cloud workers require a managed worktree", { exact: true }).waitFor();
await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("main");
const effortSelect = page.locator(
@@ -157,14 +161,16 @@ suite.define(() => {
// Picking a Gateway repo keeps the cloud selection: that folder is what
// the managed worktree checks out and dispatch syncs to the worker.
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
const projectTrigger = page.locator("#new-session-project-trigger");
const project = page.locator("wa-popover.new-session-page__project-popover");
await projectTrigger.click();
await project.getByRole("button", { name: "Browse folders" }).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(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await trigger.click();
await pollLocatorText(place.locator(".new-session-page__menu-note")).toContain(
await expect.poll(() => detailTrigger.getAttribute("data-worktree")).toBe("true");
await detailTrigger.click();
await pollLocatorText(detail.locator(".new-session-page__menu-note").last()).toContain(
"Syncs target-repo to the cloud worker",
);
await captureUiProof(page, "01-cloud-worker-target.png");
@@ -433,12 +439,12 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-where-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.locator("wa-popover.new-session-page__where-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-where-trigger");
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await gateway.setMethodResponse("environments.list", { environments: [], profiles: [] });
@@ -448,7 +454,7 @@ suite.define(() => {
.poll(async () => (await gateway.getRequests("environments.list")).length)
.toBeGreaterThan(profileRequests);
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await pollLocatorText(trigger).toContain("Cloud · aws");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("aws");
await expect
.poll(() => page.getByRole("button", { name: "Start session" }).isDisabled())
.toBe(true);
@@ -456,7 +462,7 @@ suite.define(() => {
await expect
.poll(() =>
page
.locator("wa-popover.new-session-page__place-popover")
.locator("wa-popover.new-session-page__where-popover")
.getByRole("button", { name: "Cloud · aws" })
.isDisabled(),
)
@@ -471,7 +477,9 @@ suite.define(() => {
.click();
await page.getByRole("heading", { name: "Local" }).waitFor();
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBeNull();
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("false");
await expect
.poll(() => page.locator("#new-session-detail-trigger").getAttribute("data-worktree"))
.toBe("false");
} finally {
await context.close();
}
@@ -544,9 +552,9 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-where-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.locator("wa-popover.new-session-page__where-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.evaluate(() => {
@@ -728,9 +736,9 @@ suite.define(() => {
),
).toBeNull();
await expect.poll(() => page.locator(".new-session-page__message").inputValue()).toBe("");
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-where-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.locator("wa-popover.new-session-page__where-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.locator(".new-session-page__message").fill("start another cloud task");
@@ -787,9 +795,9 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-where-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.locator("wa-popover.new-session-page__where-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.locator(".new-session-page__message").fill(message);
@@ -870,9 +878,9 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-where-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.locator("wa-popover.new-session-page__where-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.locator(".new-session-page__message").fill(message);
@@ -981,9 +989,9 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-where-trigger").click();
await page
.locator("wa-popover.new-session-page__place-popover")
.locator("wa-popover.new-session-page__where-popover")
.getByRole("button", { name: "Cloud · aws" })
.click();
await page.evaluate(() => {
@@ -74,8 +74,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
const place = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-place-trigger").click();
const place = page.locator("wa-popover.new-session-page__where-popover");
await page.locator("#new-session-where-trigger").click();
const connect = place.getByRole("button", { name: "Connect a machine…" });
await connect.waitFor();
await captureProof(page, "01-picker-foot.png");
@@ -118,9 +118,9 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
const place = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-place-trigger").click();
await place.getByText("Projects", { exact: true }).waitFor();
const place = page.locator("wa-popover.new-session-page__where-popover");
await page.locator("#new-session-where-trigger").click();
await place.getByRole("button", { name: "Local" }).waitFor();
expect(await place.getByRole("button", { name: "Connect a machine…" }).count()).toBe(0);
expect(await gateway.getRequests("device.pair.setupCode")).toEqual([]);
} finally {
@@ -137,7 +137,7 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-where-trigger").click();
await page.getByRole("button", { name: "Connect a machine…" }).click();
await gateway.waitForRequest("device.pair.setupCode");
const dialog = page.locator('openclaw-modal-dialog[label="Connect a machine"]');
@@ -101,8 +101,8 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
await gateway.waitForRequest("environments.list");
const place = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-place-trigger").click();
const place = page.locator("wa-popover.new-session-page__where-popover");
await page.locator("#new-session-where-trigger").click();
const device = place.locator('[data-value="node:capable-mac"]');
await device.waitFor();
await captureEnvironmentMetadataUiProof(page);
@@ -76,8 +76,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("projects.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-project-trigger");
const place = page.locator("wa-popover.new-session-page__project-popover");
await trigger.click();
const search = place.getByRole("searchbox", {
name: "Search projects or paste a Git URL",
@@ -111,8 +111,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("projects.list");
await page.locator("#new-session-place-trigger").click();
const place = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-project-trigger").click();
const place = page.locator("wa-popover.new-session-page__project-popover");
await place.getByRole("searchbox").fill("openclaw");
await gateway.waitForRequest("projects.searchRemote");
@@ -163,7 +163,7 @@ suite.define(() => {
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-project-trigger");
await trigger.click();
const browse = page.getByRole("button", { name: "Browse folders" });
await expect.poll(() => browse.isEnabled()).toBe(true);
@@ -230,7 +230,7 @@ suite.define(() => {
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-project-trigger").click();
await page.getByRole("button", { name: "Browse folders" }).click();
await page.getByRole("button", { name: "packages" }).click();
const useFolder = page.getByRole("button", { name: "Use this folder" });
@@ -277,7 +277,7 @@ suite.define(() => {
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-project-trigger").click();
await page.getByRole("button", { name: "Browse folders" }).click();
const pathInput = page.locator("input.new-session-page__browser-path");
await expect.poll(() => pathInput.inputValue()).toBe(workspace);
@@ -0,0 +1,145 @@
import { gatewayOriginScope } from "@openclaw/gateway-client/browser";
import { expect, it } from "vitest";
import {
WORKSPACE,
createNewSessionPageE2eSuite,
installMockGateway,
pollLocatorText,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
const GIT_BRANCHES = {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
repositoryStatus: "git",
};
const REGISTERED_PROJECT = {
id: "registered",
displayName: "Registered",
repoRoot: "/srv/registered",
source: "registered",
};
suite.define(() => {
it("restores three-chip defaults from local storage without a durable identity", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const appUrl = new URL(suite.server.baseUrl);
const gatewayUrl = `${appUrl.protocol === "https:" ? "wss:" : "ws:"}//${appUrl.host}`;
const storageKey = `openclaw.new-session.preferences.v1:${gatewayOriginScope(gatewayUrl)}`;
await page.addInitScript(
({ key, workspace }) => {
localStorage.setItem(
key,
JSON.stringify({
agents: {
main: {
workspace,
folder: workspace,
where: { kind: "local" },
projectId: "registered",
worktree: true,
baseRef: "release/local",
worktreeName: "browser-task",
},
},
}),
);
},
{ key: storageKey, workspace: WORKSPACE },
);
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
featureMethods: ["projects.list", "sessions.create", "worktrees.branches"],
methodResponses: {
"projects.list": { projects: [REGISTERED_PROJECT], recents: [] },
"worktrees.branches": GIT_BRANCHES,
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const project = page.locator("#new-session-project-trigger");
const detail = page.locator("#new-session-detail-trigger");
await expect.poll(() => project.getAttribute("data-project-id")).toBe("registered");
await pollLocatorText(project.locator(".new-session-page__trigger-label")).toBe("Registered");
await expect.poll(() => detail.getAttribute("data-worktree")).toBe("true");
await detail.click();
await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("release/local");
await expect.poll(() => page.getByLabel("Worktree name").inputValue()).toBe("browser-task");
expect(await gateway.getRequests("users.prefs.get")).toHaveLength(0);
expect(await gateway.getRequests("users.prefs.set")).toHaveLength(0);
} finally {
await context.close();
}
});
it("restores identity-scoped Where, What, and Detail defaults after discovery", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
presenceUsers: [{ self: true, id: "profile-alice", name: "Alice" }],
featureMethods: [
"chat.metadata",
"chat.startup",
"environments.list",
"node.list",
"projects.list",
"sessions.create",
"users.prefs.get",
"users.prefs.set",
"worktrees.branches",
],
methodResponses: {
"environments.list": {
environments: [{ id: "gateway", type: "local", status: "available" }],
profiles: [{ id: "aws", providerId: "crabbox" }],
},
"node.list": { nodes: [] },
"projects.list": { projects: [REGISTERED_PROJECT], recents: [] },
"users.prefs.get": {
status: "ok",
entries: {
"new-session.migration.v1": true,
"new-session.v1:main": {
workspace: WORKSPACE,
folder: WORKSPACE,
where: { kind: "cloud", id: "aws" },
projectId: "registered",
worktree: true,
baseRef: "release/next",
worktreeName: "identity-task",
},
},
},
"users.prefs.set": { status: "ok" },
"worktrees.branches": GIT_BRANCHES,
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("users.prefs.get");
const where = page.locator("#new-session-where-trigger");
const project = page.locator("#new-session-project-trigger");
const detail = page.locator("#new-session-detail-trigger");
await expect.poll(() => where.getAttribute("data-cloud-profile")).toBe("aws");
await pollLocatorText(where.locator(".new-session-page__trigger-label")).toBe("aws");
await expect.poll(() => project.getAttribute("data-project-id")).toBe("registered");
await pollLocatorText(project.locator(".new-session-page__trigger-label")).toBe("Registered");
await expect.poll(() => detail.getAttribute("data-worktree")).toBe("true");
await detail.click();
await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("release/next");
await expect.poll(() => page.getByLabel("Worktree name").inputValue()).toBe("identity-task");
await page
.locator("wa-popover.new-session-page__detail-popover")
.getByText("Cloud workers require a managed worktree", { exact: true })
.waitFor();
} finally {
await context.close();
}
});
});
@@ -11,7 +11,7 @@ import {
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("hides the destination axis when the Gateway is the only place", async () => {
it("keeps Local visible when the Gateway is the only place", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
@@ -26,12 +26,13 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw");
const trigger = page.locator("#new-session-where-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Local");
await trigger.click();
const place = page.locator("wa-popover.new-session-page__place-popover");
expect(await place.getByText("This gateway", { exact: true }).count()).toBe(0);
await place.getByText("Runs on Gateway · local", { exact: true }).waitFor();
const place = page.locator("wa-popover.new-session-page__where-popover");
await place.getByRole("button", { name: "Local" }).waitFor();
expect(await place.getByText("Your devices", { exact: true }).count()).toBe(0);
expect(await place.getByText("Cloud", { exact: true }).count()).toBe(0);
} finally {
await context.close();
}
@@ -80,8 +81,8 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
await gateway.waitForRequest("environments.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-where-trigger");
const place = page.locator("wa-popover.new-session-page__where-popover");
await trigger.click();
await place.getByRole("button", { name: "Existing Mac" }).waitFor();
const nodeRequests = (await gateway.getRequests("node.list")).length;
@@ -124,7 +125,7 @@ suite.define(() => {
.poll(async () => (await gateway.getRequests("environments.list")).length)
.toBeGreaterThan(environmentRequests);
await place.getByRole("button", { name: "New Mac" }).waitFor();
await place.getByText("This gateway", { exact: true }).waitFor();
await place.getByRole("button", { name: "Local" }).waitFor();
await place.getByText("Your devices", { exact: true }).waitFor();
expect(await place.getAttribute("open")).not.toBeNull();
+151 -97
View File
@@ -175,16 +175,17 @@ suite.define(() => {
expect(composerBox?.width).toBeCloseTo(48 * 16, 0);
expect(await page.locator(".new-session-page__message").getAttribute("rows")).toBe("1");
// The place trigger labels the workspace and opens the unified menu.
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeTrigger = page.locator("#new-session-place-trigger");
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
const projectSelect = page.locator("wa-popover.new-session-page__project-popover");
const projectTrigger = page.locator("#new-session-project-trigger");
const detailSelect = page.locator("wa-popover.new-session-page__detail-popover");
const detailTrigger = page.locator("#new-session-detail-trigger");
await pollLocatorText(projectTrigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw",
);
// Browse from the workspace, descend one level, then adopt the folder.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
await page.locator(".new-session-page__browser-entry", { hasText: "packages" }).click();
await expect
.poll(() => page.locator("input.new-session-page__browser-path").inputValue())
@@ -192,31 +193,33 @@ suite.define(() => {
await page.getByRole("button", { name: "Use this folder" }).click();
// The adopted folder closes the menu and updates the trigger label.
await expect.poll(() => placeSelect.getAttribute("open")).toBeNull();
await expect.poll(() => projectSelect.getAttribute("open")).toBeNull();
await expect
.poll(() => page.evaluate(() => document.activeElement?.id))
.toBe("new-session-place-trigger");
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
.toBe("new-session-project-trigger");
await pollLocatorText(projectTrigger.locator(".new-session-page__trigger-label")).toBe(
"packages",
);
// Git-backed custom folders stay direct until the user explicitly chooses isolation.
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false");
await placeTrigger.click();
const worktreeItem = page.getByRole("button", { name: "Worktree" });
await expect.poll(() => detailTrigger.getAttribute("data-worktree")).toBe("false");
await detailTrigger.click();
await expect.poll(() => detailTrigger.getAttribute("aria-expanded")).toBe("true");
const worktreeItem = detailSelect.getByRole("button", { name: "Worktree" });
await expect.poll(() => worktreeItem.getAttribute("aria-pressed")).toBe("false");
expect(await worktreeItem.isEnabled()).toBe(true);
await worktreeItem.click();
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true");
await expect.poll(() => detailTrigger.getAttribute("data-worktree")).toBe("true");
await page.keyboard.press("Escape");
await expect.poll(() => detailTrigger.getAttribute("aria-expanded")).toBe("false");
await expect
.poll(() => page.evaluate(() => document.activeElement?.id))
.toBe("new-session-place-trigger");
.toBe("new-session-detail-trigger");
// Pointer light-dismiss still retires the unified popover after its
// asynchronous hide animation completes.
await placeTrigger.click();
const afterPointerHide = placeSelect.evaluate(
await detailTrigger.click();
const afterPointerHide = detailSelect.evaluate(
(element) =>
new Promise<void>((resolve) => {
element.addEventListener("wa-after-hide", () => resolve(), { once: true });
@@ -224,7 +227,7 @@ suite.define(() => {
);
await page.locator(".agent-chat__welcome h2").click();
await afterPointerHide;
await expect.poll(() => placeSelect.getAttribute("open")).toBeNull();
await expect.poll(() => detailSelect.getAttribute("open")).toBeNull();
const message = page.locator(".new-session-page__message");
await message.fill("fix the flaky test");
@@ -295,8 +298,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-project-trigger");
const place = page.locator("wa-popover.new-session-page__project-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").fill("/home");
@@ -304,13 +307,17 @@ suite.define(() => {
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params)
.toEqual({ repoRoot: "/home", includeRepositoryStatus: true });
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"home · Gateway · local",
);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("home");
await trigger.click();
expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0);
const cloud = place.getByRole("button", { name: "Cloud · aws" });
const detailTrigger = page.locator("#new-session-detail-trigger");
await detailTrigger.click();
const detail = page.locator("wa-popover.new-session-page__detail-popover");
expect(await detail.getByRole("button", { name: "Worktree" }).count()).toBe(0);
await detail.getByText("Runs directly in the selected folder.", { exact: true }).waitFor();
await page.keyboard.press("Escape");
await page.locator("#new-session-where-trigger").click();
const where = page.locator("wa-popover.new-session-page__where-popover");
const cloud = where.getByRole("button", { name: "Cloud · aws" });
expect(await cloud.isDisabled()).toBe(true);
expect(await cloud.getAttribute("title")).toBe("Cloud workers require a managed worktree");
await page.keyboard.press("Escape");
@@ -355,6 +362,8 @@ suite.define(() => {
"sessions.create",
"sessions.dispatch",
"projects.list",
"environments.list",
"node.list",
"worktrees.branches",
],
methodResponses: {
@@ -375,6 +384,20 @@ suite.define(() => {
},
],
},
"node.list": {
nodes: [
{
nodeId: "macbook",
displayName: "MacBook",
connected: true,
commands: ["system.run", "fs.listDir"],
},
],
},
"environments.list": {
environments: [{ id: "node:macbook", type: "node", status: "available" }],
profiles: [],
},
"worktrees.branches": {
branches: [{ kind: "local", name: "main" }],
defaultBranch: "main",
@@ -387,10 +410,24 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("projects.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const whereTrigger = page.locator("#new-session-where-trigger");
const where = page.locator("wa-popover.new-session-page__where-popover");
await whereTrigger.click();
await expect.poll(() => whereTrigger.getAttribute("aria-expanded")).toBe("true");
await where.getByRole("button", { name: "MacBook" }).click();
await expect.poll(() => whereTrigger.getAttribute("aria-expanded")).toBe("false");
await pollLocatorText(
page.locator("#new-session-detail-trigger .new-session-page__trigger-label"),
).toBe("Node path");
await expect
.poll(() => page.evaluate(() => document.activeElement?.id))
.toBe("new-session-where-trigger");
await whereTrigger.click();
await where.getByRole("button", { name: "Local" }).click();
const trigger = page.locator("#new-session-project-trigger");
const place = page.locator("wa-popover.new-session-page__project-popover");
await trigger.click();
await place.getByText("Projects", { exact: true }).waitFor();
await place.getByRole("button", { name: "Recorded OpenClaw", exact: true }).click();
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"Recorded OpenClaw",
@@ -400,8 +437,11 @@ suite.define(() => {
.poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params)
.toEqual({ repoRoot: "/recorded/openclaw", includeRepositoryStatus: true });
await trigger.click();
await place.getByRole("button", { name: "Worktree" }).click();
await page.locator("#new-session-detail-trigger").click();
await page
.locator("wa-popover.new-session-page__detail-popover")
.getByRole("button", { name: "Worktree" })
.click();
await captureProjectUiProof(page, "project-selected.png");
await page.keyboard.press("Escape");
await page.locator(".new-session-page__message").fill("inspect the project");
@@ -474,8 +514,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("projects.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-project-trigger");
const place = page.locator("wa-popover.new-session-page__project-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
const pathInput = page.locator("input.new-session-page__browser-path");
@@ -510,9 +550,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("projects.list");
const place = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-place-trigger").click();
await place.getByText("Projects", { exact: true }).waitFor();
const place = page.locator("wa-popover.new-session-page__project-popover");
await page.locator("#new-session-project-trigger").click();
await place
.getByText("Admins can register projects from Browse folders", { exact: true })
.waitFor();
@@ -552,14 +591,17 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("system.info");
const trigger = page.locator("#new-session-place-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw · Gateway · Peters-Mac-Studio",
);
const trigger = page.locator("#new-session-where-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Local");
await trigger.click();
const place = page.locator("wa-popover.new-session-page__place-popover");
await place.getByRole("button", { name: "Gateway · Peters-Mac-Studio" }).waitFor();
await place.getByRole("button", { name: "Browse folders" }).click();
const place = page.locator("wa-popover.new-session-page__where-popover");
await place.getByRole("button", { name: /Local/u }).waitFor();
await page.keyboard.press("Escape");
await page.locator("#new-session-project-trigger").click();
await page
.locator("wa-popover.new-session-page__project-popover")
.getByRole("button", { name: "Browse folders" })
.click();
await expect
.poll(() =>
page.locator("input.new-session-page__browser-path").getAttribute("placeholder"),
@@ -572,9 +614,9 @@ suite.define(() => {
await expect
.poll(async () => (await gateway.getRequests("node.list")).length)
.toBeGreaterThan(nodeRequests);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Local");
await trigger.click();
await place.getByText("Runs on Gateway · Peters-Mac-Studio", { exact: true }).waitFor();
await place.getByRole("button", { name: /Local/u }).waitFor();
} finally {
await context.close();
}
@@ -626,7 +668,7 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-where-trigger");
await trigger.click();
const first = page.locator('[data-value="node:11111111aaaaaaaa"]');
const second = page.locator('[data-value="node:22222222bbbbbbbb"]');
@@ -640,9 +682,7 @@ suite.define(() => {
expect(await first.getAttribute("title")).toBe("macOS · Mac14,12 · 192.168.1.11");
expect(await second.getAttribute("title")).toContain("192.168.1.12");
await second.click();
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"Agent workspace · Mac Studio",
);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Mac Studio");
expect(await trigger.textContent()).not.toContain("Mac15,14");
expect(await trigger.textContent()).not.toContain("192.168.1.12");
} finally {
@@ -676,7 +716,7 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-project-trigger");
await trigger.click();
const first = page.locator('[data-value="recent::/a/openclaw"]');
const second = page.locator('[data-value="recent::/b/openclaw"]');
@@ -740,15 +780,16 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-project-trigger");
await trigger.click();
await page
.locator("wa-popover.new-session-page__place-popover")
.getByRole("button", { name: "Projects · MacBook", exact: true })
.locator("wa-popover.new-session-page__project-popover")
.getByRole("button", { name: /Projects.*MacBook/u })
.click();
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"Projects · MacBook",
);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Projects");
await pollLocatorText(
page.locator("#new-session-where-trigger .new-session-page__trigger-label"),
).toBe("MacBook");
await page.locator(".new-session-page__message").fill("continue on the recent node");
await page.getByRole("button", { name: "Start session" }).click();
@@ -785,18 +826,18 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-project-trigger");
const place = page.locator("wa-popover.new-session-page__project-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await gateway.waitForRequest("fs.listDir");
await place.getByRole("button", { name: "Parent folder" }).click();
await place.getByRole("button", { name: "Worktree" }).waitFor();
await place.getByRole("button", { name: "Browse folders" }).waitFor();
expect(await place.getAttribute("open")).not.toBeNull();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").press("Escape");
await place.getByRole("button", { name: "Worktree" }).waitFor();
await place.getByRole("button", { name: "Browse folders" }).waitFor();
expect(await place.getAttribute("open")).not.toBeNull();
} finally {
await context.close();
@@ -894,24 +935,32 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator(".new-session-page__message").waitFor();
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeTrigger = page.locator("#new-session-place-trigger");
const placeLabel = placeTrigger.locator(".new-session-page__trigger-label");
const whereSelect = page.locator("wa-popover.new-session-page__where-popover");
const whereTrigger = page.locator("#new-session-where-trigger");
const whereLabel = whereTrigger.locator(".new-session-page__trigger-label");
const projectSelect = page.locator("wa-popover.new-session-page__project-popover");
const projectTrigger = page.locator("#new-session-project-trigger");
const projectLabel = projectTrigger.locator(".new-session-page__trigger-label");
const detailSelect = page.locator("wa-popover.new-session-page__detail-popover");
const detailTrigger = page.locator("#new-session-detail-trigger");
const browserEntries = page.locator(".new-session-page__browser-list");
// Pick the node from Your devices.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "MacBook" }).click();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
// Node sessions cannot use managed worktrees, so the menu drops the item.
await placeTrigger.click();
expect(await placeSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0);
await whereTrigger.click();
await whereSelect.getByRole("button", { name: "MacBook" }).click();
await pollLocatorText(whereLabel).toBe("MacBook");
await pollLocatorText(detailTrigger.locator(".new-session-page__trigger-label")).toBe(
"Node path",
);
await detailTrigger.click();
expect(await detailSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0);
await detailSelect.getByLabel("Working directory").waitFor();
await page.keyboard.press("Escape");
// Manual path entry in the browser head preserves UNC paths; these
// cannot be rediscovered by starting at the node home directory.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
const pathInput = page.locator("input.new-session-page__browser-path");
await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME);
await pathInput.fill(NODE_UNC);
@@ -921,24 +970,27 @@ suite.define(() => {
await page.keyboard.press("Escape");
await expect
.poll(() =>
placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
projectSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
)
.toBe(true);
await placeSelect.getByText("Your devices", { exact: true }).waitFor();
await projectSelect.getByRole("button", { name: "Browse folders" }).waitFor();
// Destination selection stays in the destination sections; browsing is fixed to the target.
await placeSelect.getByRole("button", { name: "Gateway · local" }).click();
await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local");
await placeTrigger.click();
expect(await placeSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0);
await placeSelect.getByRole("button", { name: "MacBook" }).click();
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
// Destination selection stays in Where; browsing remains fixed to the selected target.
await page.keyboard.press("Escape");
await whereTrigger.click();
await whereSelect.getByRole("button", { name: "Local" }).click();
await pollLocatorText(whereLabel).toBe("Local");
await whereTrigger.click();
expect(await whereSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0);
await whereSelect.getByRole("button", { name: "MacBook" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
await browserEntries.getByRole("button", { name: "Projects" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
// Using a node folder retargets the draft to that node.
await pollLocatorText(placeLabel).toBe("Projects · MacBook");
await pollLocatorText(whereLabel).toBe("MacBook");
await pollLocatorText(projectLabel).toBe("Projects");
// A node cwd belongs to the selected agent's draft and must not leak
// across an agent change, even though the execution node stays selected.
@@ -949,39 +1001,41 @@ suite.define(() => {
.filter({ hasText: "Research" })
.click();
await page.getByRole("heading", { name: "Research" }).waitFor();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
await pollLocatorText(whereLabel).toBe("MacBook");
await pollLocatorText(projectLabel).toBe("research");
// Clearing the path applies the node's default directory (empty folder),
// the state the replaced clearable folder textbox could express.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME);
await pathInput.fill("");
await page.getByRole("button", { name: "Use this folder" }).click();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
await pollLocatorText(projectLabel).toBe("research");
// Browse back to the custom folder, then retarget to the exec-only node
// with a manual absolute path for the final create assertion.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
await browserEntries.getByRole("button", { name: "Projects" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
await pollLocatorText(placeLabel).toBe("Projects · MacBook");
await pollLocatorText(projectLabel).toBe("Projects");
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Old node" }).click();
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await expect.poll(() => pathInput.inputValue()).toBe("");
await pathInput.fill(EXEC_ONLY_PICKED);
await pathInput.press("Enter");
await whereTrigger.click();
await whereSelect.getByRole("button", { name: "Old node" }).click();
await detailTrigger.click();
const nodeCwd = detailSelect.getByLabel("Working directory");
await expect.poll(() => nodeCwd.inputValue()).toBe("");
await nodeCwd.fill(EXEC_ONLY_PICKED);
await nodeCwd.press("Enter");
expect(
(await gateway.getRequests("fs.listDir")).filter(
(request) => (request.params as { nodeId?: string } | undefined)?.nodeId === "old-node",
),
).toHaveLength(0);
await page.getByRole("button", { name: "Use this folder" }).click();
await pollLocatorText(placeLabel).toBe("repo · Old node");
await page.keyboard.press("Escape");
await pollLocatorText(whereLabel).toBe("Old node");
await pollLocatorText(projectLabel).toBe("repo");
await page.locator(".new-session-page__message").fill("inspect the remote checkout");
await page.getByRole("button", { name: "Start session" }).click();
@@ -81,8 +81,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("projects.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-project-trigger");
const place = page.locator("wa-popover.new-session-page__project-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
const pathInput = page.locator("input.new-session-page__browser-path");
@@ -117,9 +117,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("projects.list");
const place = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-place-trigger").click();
await place.getByText("Projects", { exact: true }).waitFor();
const place = page.locator("wa-popover.new-session-page__project-popover");
await page.locator("#new-session-project-trigger").click();
await place
.getByText("Admins can register projects from Browse folders", { exact: true })
.waitFor();
@@ -129,7 +128,7 @@ suite.define(() => {
}
});
it("hides the destination axis when the Gateway is the only place", async () => {
it("keeps the Local destination visible when the Gateway is the only place", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
@@ -147,12 +146,13 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw");
const trigger = page.locator("#new-session-where-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Local");
await trigger.click();
const place = page.locator("wa-popover.new-session-page__place-popover");
expect(await place.getByText("Places", { exact: true }).count()).toBe(0);
await place.getByText("Runs on Gateway · local", { exact: true }).waitFor();
const place = page.locator("wa-popover.new-session-page__where-popover");
await place.getByRole("button", { name: "Local" }).waitFor();
expect(await place.getByText("Your devices", { exact: true }).count()).toBe(0);
expect(await place.getByText("Cloud", { exact: true }).count()).toBe(0);
} finally {
await context.close();
}
@@ -191,14 +191,17 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("system.info");
const trigger = page.locator("#new-session-place-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw · Gateway · Peters-Mac-Studio",
);
const trigger = page.locator("#new-session-where-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Local");
await trigger.click();
const place = page.locator("wa-popover.new-session-page__place-popover");
await place.getByRole("button", { name: "Gateway · Peters-Mac-Studio" }).waitFor();
await place.getByRole("button", { name: "Browse folders" }).click();
const place = page.locator("wa-popover.new-session-page__where-popover");
await place.getByRole("button", { name: /Local/u }).waitFor();
await page.keyboard.press("Escape");
await page.locator("#new-session-project-trigger").click();
await page
.locator("wa-popover.new-session-page__project-popover")
.getByRole("button", { name: "Browse folders" })
.click();
await expect
.poll(() =>
page.locator("input.new-session-page__browser-path").getAttribute("placeholder"),
@@ -211,9 +214,9 @@ suite.define(() => {
await expect
.poll(async () => (await gateway.getRequests("node.list")).length)
.toBeGreaterThan(nodeRequests);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Local");
await trigger.click();
await place.getByText("Runs on Gateway · Peters-Mac-Studio", { exact: true }).waitFor();
await place.getByRole("button", { name: /Local/u }).waitFor();
} finally {
await context.close();
}
@@ -247,8 +250,8 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
await gateway.waitForRequest("environments.list");
await page.locator("#new-session-place-trigger").click();
const place = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-where-trigger").click();
const place = page.locator("wa-popover.new-session-page__where-popover");
await place.getByText("Your devices", { exact: true }).waitFor();
await place.getByRole("button", { name: "Fallback device" }).waitFor();
await captureUiProof(page, "04-catalog-unavailable-device-fallback.png");
@@ -282,8 +285,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-where-trigger");
const place = page.locator("wa-popover.new-session-page__where-popover");
await trigger.click();
await place.getByRole("button", { name: "Stable device" }).waitFor();
await page.keyboard.press("Escape");
@@ -361,13 +364,10 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-where-trigger");
await trigger.click();
const place = page.locator("wa-popover.new-session-page__place-popover");
await place
.locator(".new-session-page__menu-title")
.getByText("This gateway", { exact: true })
.waitFor();
const place = page.locator("wa-popover.new-session-page__where-popover");
await place.getByRole("button", { name: /Local/u }).waitFor();
await place.getByText("Your devices", { exact: true }).waitFor();
const first = page.locator('[data-value="node:11111111aaaaaaaa"]');
const second = page.locator('[data-value="node:22222222bbbbbbbb"]');
@@ -382,9 +382,7 @@ suite.define(() => {
expect(await second.getAttribute("title")).toContain("192.168.1.12");
await captureUiProof(page, "03-legacy-device-picker.png");
await second.click();
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"Agent workspace · Mac Studio",
);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Mac Studio");
expect(await trigger.textContent()).not.toContain("Mac15,14");
expect(await trigger.textContent()).not.toContain("192.168.1.12");
} finally {
@@ -418,7 +416,7 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-project-trigger");
await trigger.click();
const first = page.locator('[data-value="recent::/a/openclaw"]');
const second = page.locator('[data-value="recent::/b/openclaw"]');
@@ -492,15 +490,16 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-project-trigger");
await trigger.click();
await page
.locator("wa-popover.new-session-page__place-popover")
.getByRole("button", { name: "Projects · MacBook", exact: true })
.locator("wa-popover.new-session-page__project-popover")
.getByRole("button", { name: /Projects.*MacBook/u })
.click();
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"Projects · MacBook",
);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("Projects");
await pollLocatorText(
page.locator("#new-session-where-trigger .new-session-page__trigger-label"),
).toBe("MacBook");
await page.locator(".new-session-page__message").fill("continue on the recent node");
await page.getByRole("button", { name: "Start session" }).click();
@@ -562,8 +561,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-project-trigger");
const place = page.locator("wa-popover.new-session-page__project-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").fill("/home");
@@ -571,18 +570,17 @@ suite.define(() => {
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params)
.toEqual({ repoRoot: "/home", includeRepositoryStatus: true });
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
"home · Gateway · local",
);
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("home");
await trigger.click();
expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0);
await place
.locator(".new-session-page__menu-title")
.getByText("This gateway", { exact: true })
.waitFor();
await place.getByText("Cloud", { exact: true }).waitFor();
const cloud = place.getByRole("button", { name: "Cloud · aws" });
await page.locator("#new-session-detail-trigger").click();
const detail = page.locator("wa-popover.new-session-page__detail-popover");
expect(await detail.getByRole("button", { name: "Worktree" }).count()).toBe(0);
await detail.getByText("Runs directly in the selected folder.", { exact: true }).waitFor();
await page.keyboard.press("Escape");
await page.locator("#new-session-where-trigger").click();
const where = page.locator("wa-popover.new-session-page__where-popover");
await where.getByText("Cloud", { exact: true }).waitFor();
const cloud = where.getByRole("button", { name: "Cloud · aws" });
expect(await cloud.isDisabled()).toBe(true);
expect(await cloud.getAttribute("title")).toBe("Cloud workers require a managed worktree");
await page.keyboard.press("Escape");
@@ -702,29 +700,29 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator(".new-session-page__message").waitFor();
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeTrigger = page.locator("#new-session-place-trigger");
const placeLabel = placeTrigger.locator(".new-session-page__trigger-label");
const whereSelect = page.locator("wa-popover.new-session-page__where-popover");
const whereTrigger = page.locator("#new-session-where-trigger");
const whereLabel = whereTrigger.locator(".new-session-page__trigger-label");
const projectSelect = page.locator("wa-popover.new-session-page__project-popover");
const projectTrigger = page.locator("#new-session-project-trigger");
const projectLabel = projectTrigger.locator(".new-session-page__trigger-label");
const detailSelect = page.locator("wa-popover.new-session-page__detail-popover");
const detailTrigger = page.locator("#new-session-detail-trigger");
const browserEntries = page.locator(".new-session-page__browser-list");
// Pick the node from Devices.
await placeTrigger.click();
await placeSelect
.locator(".new-session-page__menu-title")
.getByText("This gateway", { exact: true })
.waitFor();
await placeSelect.getByText("Your devices", { exact: true }).waitFor();
await placeSelect.getByRole("button", { name: "MacBook" }).click();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
// Node sessions cannot use managed worktrees, so the menu drops the item.
await placeTrigger.click();
expect(await placeSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0);
await whereTrigger.click();
await whereSelect.getByText("Your devices", { exact: true }).waitFor();
await whereSelect.getByRole("button", { name: "MacBook" }).click();
await pollLocatorText(whereLabel).toBe("MacBook");
await detailTrigger.click();
expect(await detailSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0);
await detailSelect.getByLabel("Working directory").waitFor();
await page.keyboard.press("Escape");
// Manual path entry in the browser head preserves UNC paths; these
// cannot be rediscovered by starting at the node home directory.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
const pathInput = page.locator("input.new-session-page__browser-path");
await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME);
await pathInput.fill(NODE_UNC);
@@ -734,27 +732,26 @@ suite.define(() => {
await page.keyboard.press("Escape");
await expect
.poll(() =>
placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
projectSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
)
.toBe(true);
await placeSelect
.locator(".new-session-page__menu-title")
.getByText("This gateway", { exact: true })
.waitFor();
await projectSelect.getByRole("button", { name: "Browse folders" }).waitFor();
// Destination selection stays grouped by place; browsing is fixed to the current target.
await placeSelect.getByRole("button", { name: "Gateway · local" }).click();
await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local");
await placeTrigger.click();
expect(await placeSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0);
await placeSelect.getByRole("button", { name: "MacBook" }).click();
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await page.keyboard.press("Escape");
await whereTrigger.click();
await whereSelect.getByRole("button", { name: "Local" }).click();
await pollLocatorText(whereLabel).toBe("Local");
await whereTrigger.click();
expect(await whereSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0);
await whereSelect.getByRole("button", { name: "MacBook" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
await browserEntries.getByRole("button", { name: "Projects" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
// Using a node folder retargets the draft to that node.
await pollLocatorText(placeLabel).toBe("Projects · MacBook");
await pollLocatorText(whereLabel).toBe("MacBook");
await pollLocatorText(projectLabel).toBe("Projects");
// A node cwd belongs to the selected agent's draft and must not leak
// across an agent change, even though the execution node stays selected.
@@ -765,39 +762,41 @@ suite.define(() => {
.filter({ hasText: "Research" })
.click();
await page.getByRole("heading", { name: "Research" }).waitFor();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
await pollLocatorText(whereLabel).toBe("MacBook");
await pollLocatorText(projectLabel).toBe("research");
// Clearing the path applies the node's default directory (empty folder),
// the state the replaced clearable folder textbox could express.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME);
await pathInput.fill("");
await page.getByRole("button", { name: "Use this folder" }).click();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
await pollLocatorText(projectLabel).toBe("research");
// Browse back to the custom folder, then retarget to the exec-only node
// with a manual absolute path for the final create assertion.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
await browserEntries.getByRole("button", { name: "Projects" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
await pollLocatorText(placeLabel).toBe("Projects · MacBook");
await pollLocatorText(projectLabel).toBe("Projects");
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Old node" }).click();
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await expect.poll(() => pathInput.inputValue()).toBe("");
await pathInput.fill(EXEC_ONLY_PICKED);
await pathInput.press("Enter");
await whereTrigger.click();
await whereSelect.getByRole("button", { name: "Old node" }).click();
await detailTrigger.click();
const nodeCwd = detailSelect.getByLabel("Working directory");
await expect.poll(() => nodeCwd.inputValue()).toBe("");
await nodeCwd.fill(EXEC_ONLY_PICKED);
await nodeCwd.press("Enter");
expect(
(await gateway.getRequests("fs.listDir")).filter(
(request) => (request.params as { nodeId?: string } | undefined)?.nodeId === "old-node",
),
).toHaveLength(0);
await page.getByRole("button", { name: "Use this folder" }).click();
await pollLocatorText(placeLabel).toBe("repo · Old node");
await page.keyboard.press("Escape");
await pollLocatorText(whereLabel).toBe("Old node");
await pollLocatorText(projectLabel).toBe("repo");
await page.locator(".new-session-page__message").fill("inspect the remote checkout");
await page.getByRole("button", { name: "Start session" }).click();
@@ -36,6 +36,7 @@ suite.define(() => {
it("grows the first prompt downward without moving the identity, then caps at ten lines", async () => {
await withNewSessionPage(async (page) => {
const gateway = await installMockGateway(page);
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto(`${suite.server.baseUrl}new`);
const message = page.locator(".new-session-page__message");
await message.waitFor();
@@ -642,8 +643,8 @@ suite.define(() => {
const draft = page.locator(".new-session-page__scroll");
const message = page.locator(".new-session-page__message");
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeSummary = page.locator("#new-session-place-trigger");
const placeSelect = page.locator("wa-popover.new-session-page__project-popover");
const placeSummary = page.locator("#new-session-project-trigger");
await message.fill(submittedMessage);
await placeSummary.click();
+1 -1
View File
@@ -235,7 +235,7 @@ export async function waitForCommittedChatRoute(page: Page) {
}
export async function choosePackagesFolder(page: Page) {
await page.locator("#new-session-place-trigger").click();
await page.locator("#new-session-project-trigger").click();
await page.getByRole("button", { name: "Browse folders" }).click();
await page.locator(".new-session-page__browser-entry", { hasText: "packages" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
@@ -400,10 +400,13 @@ suite.define(() => {
},
});
await page.goto(`${suite.server.baseUrl}new`);
const placeTrigger = page.locator("#new-session-place-trigger");
const placeTrigger = page.locator("#new-session-detail-trigger");
const projectTrigger = page.locator("#new-session-project-trigger");
await choosePackagesFolder(page);
await placeTrigger.click();
await page.getByRole("button", { name: "Worktree" }).click();
await page.getByLabel("Base branch").fill("release/next");
await page.getByLabel("Worktree name").fill("remembered-task");
await page.keyboard.press("Escape");
const modelSelect = page.locator('[data-chat-model-select="true"]');
@@ -416,10 +419,19 @@ suite.define(() => {
await expect.poll(() => effortSelect.getAttribute("data-chat-thinking-value")).toBe("high");
await page.goto(`${suite.server.baseUrl}new`);
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
await pollLocatorText(projectTrigger.locator(".new-session-page__trigger-label")).toBe(
"packages",
);
await pollLocatorText(
page.locator("#new-session-where-trigger .new-session-page__trigger-label"),
).toBe("Local");
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true");
await placeTrigger.click();
await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("release/next");
await expect
.poll(() => page.getByLabel("Worktree name").inputValue())
.toBe("remembered-task");
await page.keyboard.press("Escape");
await expect
.poll(() => modelSelect.getAttribute("data-chat-select-value"))
.toBe("anthropic/claude-sonnet-4-6");
@@ -504,7 +516,7 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-project-trigger");
await trigger.click();
expect(await page.locator('[data-value="recent::/shared"]').count()).toBe(0);
const recent = page.locator('[data-value="recent-project:registered"]');
@@ -609,9 +621,10 @@ suite.define(() => {
},
},
});
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-project-trigger");
const detailTrigger = page.locator("#new-session-detail-trigger");
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("packages");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await expect.poll(() => detailTrigger.getAttribute("data-worktree")).toBe("true");
await captureProjectUiProof(page, "identity-preferences-migrated.png");
await navigateInApp(page, "chat");
@@ -705,7 +718,7 @@ suite.define(() => {
});
await page.goto(`${suite.server.baseUrl}new`);
await choosePackagesFolder(page);
const placeTrigger = page.locator("#new-session-place-trigger");
const placeTrigger = page.locator("#new-session-detail-trigger");
await placeTrigger.click();
await page.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
@@ -780,7 +793,7 @@ suite.define(() => {
},
});
await page.goto(`${suite.server.baseUrl}new`);
const placeTrigger = page.locator("#new-session-place-trigger");
const placeTrigger = page.locator("#new-session-project-trigger");
await placeTrigger.click();
await page.getByRole("button", { name: "Browse folders" }).click();
await page.getByRole("button", { name: "Use this folder" }).click();
@@ -847,7 +860,7 @@ suite.define(() => {
code: "INVALID_REQUEST",
message: `Error: ENOENT: no such file or directory, scandir '${PICKED}'`,
});
const placeTrigger = page.locator("#new-session-place-trigger");
const placeTrigger = page.locator("#new-session-project-trigger");
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw",
);
@@ -865,7 +878,9 @@ suite.define(() => {
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"openclaw",
);
await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false");
await expect
.poll(() => page.locator("#new-session-detail-trigger").getAttribute("data-worktree"))
.toBe("false");
await expect
.poll(
async () =>
@@ -913,10 +928,10 @@ suite.define(() => {
.poll(async () => (await gateway.getRequests("fs.listDir")).length)
.toBeGreaterThan(validationRequests);
const placeTrigger = page.locator("#new-session-place-trigger");
const placeTrigger = page.locator("#new-session-project-trigger");
await placeTrigger.click();
await page
.locator('wa-popover.new-session-page__place-popover [data-value="workspace"]')
.locator('wa-popover.new-session-page__project-popover [data-value="workspace"]')
.click();
await gateway.resolveDeferred("fs.listDir", {
path: PICKED,
@@ -947,7 +962,7 @@ suite.define(() => {
},
});
await page.goto(`${suite.server.baseUrl}new`);
const trigger = page.locator("#new-session-place-trigger");
const trigger = page.locator("#new-session-project-trigger");
await trigger.click();
await page.getByRole("button", { name: "Browse folders" }).click();
const browserPath = page.locator("input.new-session-page__browser-path");
@@ -69,8 +69,8 @@ async function withNewSessionPage(
type MockGateway = Awaited<ReturnType<typeof installMockGateway>>;
async function chooseCustomFolder(page: Page, gateway: MockGateway) {
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-project-trigger");
const place = page.locator("wa-popover.new-session-page__project-popover");
await trigger.click();
await place.getByRole("button", { name: "Browse folders" }).click();
await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO);
@@ -105,8 +105,8 @@ suite.define(() => {
});
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("worktrees.branches");
const trigger = page.locator("#new-session-place-trigger");
const place = page.locator("wa-popover.new-session-page__place-popover");
const trigger = page.locator("#new-session-detail-trigger");
const place = page.locator("wa-popover.new-session-page__detail-popover");
await trigger.click();
await place.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
@@ -156,7 +156,9 @@ suite.define(() => {
},
});
await page.goto(`${suite.server.baseUrl}new`);
const { place, trigger } = await chooseCustomFolder(page, gateway);
await chooseCustomFolder(page, gateway);
const trigger = page.locator("#new-session-detail-trigger");
const place = page.locator("wa-popover.new-session-page__detail-popover");
await trigger.click();
await place.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
@@ -204,7 +206,9 @@ suite.define(() => {
},
});
await page.goto(`${suite.server.baseUrl}new`);
const { place, trigger } = await chooseCustomFolder(page, gateway);
await chooseCustomFolder(page, gateway);
const trigger = page.locator("#new-session-detail-trigger");
const place = page.locator("wa-popover.new-session-page__detail-popover");
await trigger.click();
await place.getByRole("button", { name: "Worktree" }).click();
await page.keyboard.press("Escape");
@@ -258,11 +262,15 @@ suite.define(() => {
});
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("environments.list");
const { place, trigger } = await chooseCustomFolder(page, gateway);
await trigger.click();
await place.getByRole("button", { name: "Cloud · aws" }).click();
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await chooseCustomFolder(page, gateway);
const whereTrigger = page.locator("#new-session-where-trigger");
const where = page.locator("wa-popover.new-session-page__where-popover");
const detailTrigger = page.locator("#new-session-detail-trigger");
const detail = page.locator("wa-popover.new-session-page__detail-popover");
await whereTrigger.click();
await where.getByRole("button", { name: "Cloud · aws" }).click();
await expect.poll(() => whereTrigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => detailTrigger.getAttribute("data-worktree")).toBe("true");
await gateway.setMethodResponse("worktrees.branches", {
branches: [],
@@ -270,14 +278,15 @@ suite.define(() => {
});
await reconnectForBranchRediscovery(page, gateway);
await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true");
await expect.poll(() => whereTrigger.getAttribute("data-cloud-profile")).toBe("aws");
await expect.poll(() => detailTrigger.getAttribute("data-worktree")).toBe("true");
await page.locator(".new-session-page__message").fill("do not run directly");
const start = page.getByRole("button", { name: "Start session" });
await expect.poll(() => start.isDisabled()).toBe(true);
await trigger.click();
const cloud = place.getByRole("button", { name: "Cloud · aws" });
const worktree = place.getByRole("button", { name: "Worktree" });
await whereTrigger.click();
const cloud = where.getByRole("button", { name: "Cloud · aws" });
await detailTrigger.click();
const worktree = detail.getByRole("button", { name: "Worktree" });
expect(await cloud.isDisabled()).toBe(true);
expect(await cloud.getAttribute("title")).toBe(
"Couldn't verify Git for this folder. Choose it again to retry.",
@@ -314,8 +323,8 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("node.list");
await gateway.waitForRequest("environments.list");
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
await page.locator("#new-session-place-trigger").click();
const placeSelect = page.locator("wa-popover.new-session-page__where-popover");
await page.locator("#new-session-where-trigger").click();
await placeSelect.getByRole("button", { name: "Old device" }).click();
await page.locator(".new-session-page__message").fill("use a validated device");
const start = page.locator("button.chat-send-btn");
@@ -370,17 +379,19 @@ suite.define(() => {
await gateway.waitForRequest("worktrees.branches");
const message = page.locator(".new-session-page__message");
const placeSelect = page.locator("wa-popover.new-session-page__place-popover");
const placeTrigger = page.locator("#new-session-place-trigger");
const whereSelect = page.locator("wa-popover.new-session-page__where-popover");
const whereTrigger = page.locator("#new-session-where-trigger");
const projectSelect = page.locator("wa-popover.new-session-page__project-popover");
const projectTrigger = page.locator("#new-session-project-trigger");
await message.fill("preserve this replacement draft");
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Old device" }).click();
await whereTrigger.click();
await whereSelect.getByRole("button", { name: "Old device" }).click();
// Keep an old-client browser request in flight. Replacement must close
// its menu and prevent its eventual completion from reviving old state.
await gateway.deferNext("fs.listDir");
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "Browse folders" }).click();
await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click();
await gateway.waitForRequest("fs.listDir");
await gateway.setMethodResponse(
@@ -423,11 +434,11 @@ suite.define(() => {
await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft");
await expect
.poll(() =>
placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
projectSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
)
.toBe(false);
await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe(
"target-repo · Gateway · local",
await pollLocatorText(projectTrigger.locator(".new-session-page__trigger-label")).toBe(
"target-repo",
);
const branchRequests = await gateway.getRequests("worktrees.branches");
@@ -435,10 +446,13 @@ suite.define(() => {
repoRoot: TARGET_REPO,
includeRepositoryStatus: true,
});
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "New device" }).waitFor();
expect(await placeSelect.getByRole("button", { name: "Old device" }).count()).toBe(0);
await placeSelect.getByRole("button", { name: "Worktree" }).click();
await whereTrigger.click();
await whereSelect.getByRole("button", { name: "New device" }).waitFor();
expect(await whereSelect.getByRole("button", { name: "Old device" }).count()).toBe(0);
await page.keyboard.press("Escape");
const detailSelect = page.locator("wa-popover.new-session-page__detail-popover");
await page.locator("#new-session-detail-trigger").click();
await detailSelect.getByRole("button", { name: "Worktree" }).click();
await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("beta");
await page.keyboard.press("Escape");
@@ -449,7 +463,7 @@ suite.define(() => {
});
await expect
.poll(() =>
placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
projectSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
)
.toBe(false);
await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft");
@@ -555,7 +569,7 @@ suite.define(() => {
});
await page.goto(`${suite.server.baseUrl}new?agent=research`);
const folderLabel = page.locator(
"#new-session-place-trigger .new-session-page__trigger-label",
"#new-session-project-trigger .new-session-page__trigger-label",
);
await pollLocatorText(folderLabel).toBe("research");
+7
View File
@@ -784,6 +784,13 @@ export const en: TranslationMap = {
cloudSetupInterrupted:
"This cloud session's setup was interrupted. Check recent sessions before starting this task again.",
catalogUnavailable: "This session target is unavailable.",
what: "What",
detail: "Detail",
local: "Local",
nodePath: "Node path",
nodeCwd: "Working directory",
runsDirectly: "Runs directly",
runsDirectlyNote: "Runs directly in the selected folder.",
},
dashboardsPage: {
emptyTitle: "No dashboards yet",
@@ -0,0 +1,76 @@
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderDetailChip, resolveDetailChip } from "./detail-chip.ts";
describe("Detail chip state", () => {
it.each([
{
name: "shows a node cwd decision",
params: {
execNode: "macbook",
cloudProfileId: "",
worktree: false,
repository: { kind: "idle" as const },
},
expected: { mode: "node", label: "Node path", worktreeLocked: false },
},
{
name: "keeps cloud worktrees visibly locked",
params: {
execNode: "",
cloudProfileId: "fleet",
worktree: true,
repository: { kind: "git" as const, repoRoot: "/repo", branches: [] },
},
expected: { mode: "cloud", label: "Worktree", worktreeLocked: true },
},
{
name: "explains plain-folder execution",
params: {
execNode: "",
cloudProfileId: "",
worktree: false,
repository: { kind: "direct" as const, repoRoot: "/folder" },
},
expected: { mode: "direct", label: "Runs directly", worktreeLocked: false },
},
])("$name", ({ params, expected }) => {
expect(resolveDetailChip(params)).toEqual(expected);
});
it("renders the cloud lock reason as visible text", () => {
const container = document.createElement("div");
render(
renderDetailChip({
state: { mode: "cloud", label: "Worktree", worktreeLocked: true },
syncLabel: "OpenClaw",
folder: "/repo",
execNode: "",
worktree: true,
worktreeAvailable: true,
branches: { repoRoot: "/repo", branches: [] },
branchesLoading: false,
baseRef: "main",
worktreeName: "",
submitting: false,
pendingCloud: false,
popoverOpen: true,
popoverHiding: false,
onGuardTransition: () => undefined,
onPopoverShow: () => undefined,
onPopoverHide: () => undefined,
onPopoverAfterHide: () => undefined,
onToggleWorktree: () => undefined,
onBaseRefInput: () => undefined,
onWorktreeNameInput: () => undefined,
onNodeFolderInput: () => undefined,
}),
container,
);
const worktree = container.querySelector<HTMLButtonElement>('[data-value="worktree"]');
expect(worktree?.disabled).toBe(true);
expect(container.textContent).toContain("Cloud workers require a managed worktree");
expect(container.textContent).toContain("Syncs OpenClaw to the cloud worker");
});
});
+210
View File
@@ -0,0 +1,210 @@
import { html, nothing } from "lit";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import { renderSessionMenuItem } from "./cloud-target.ts";
import type { DraftBranches, DraftRepositoryState } from "./discovery.ts";
type DetailChipState = Readonly<{
mode: "node" | "cloud" | "git" | "direct" | "checking" | "unavailable";
label: string;
worktreeLocked: boolean;
}>;
export function resolveDetailChip(params: {
execNode: string;
cloudProfileId: string;
worktree: boolean;
repository: DraftRepositoryState;
}): DetailChipState {
if (params.execNode) {
return { mode: "node", label: t("newSession.nodePath"), worktreeLocked: false };
}
if (params.cloudProfileId) {
return { mode: "cloud", label: t("newSession.worktree"), worktreeLocked: true };
}
if (params.repository.kind === "git" || params.worktree) {
return {
mode: "git",
label: params.worktree ? t("newSession.worktree") : t("newSession.runsDirectly"),
worktreeLocked: false,
};
}
if (params.repository.kind === "checking") {
return { mode: "checking", label: t("newSession.checkingGit"), worktreeLocked: false };
}
if (params.repository.kind === "unavailable") {
return {
mode: "unavailable",
label: t("newSession.runsDirectly"),
worktreeLocked: false,
};
}
return { mode: "direct", label: t("newSession.runsDirectly"), worktreeLocked: false };
}
export function renderDetailChip(params: {
state: DetailChipState;
syncLabel: string;
folder: string;
execNode: string;
worktree: boolean;
worktreeAvailable: boolean;
worktreeDisabledReason?: string;
branches: DraftBranches | null;
branchesLoading: boolean;
baseRef: string;
worktreeName: string;
submitting: boolean;
pendingCloud: boolean;
popoverOpen: boolean;
popoverHiding: boolean;
onGuardTransition: (event: MouseEvent) => void;
onPopoverShow: () => void;
onPopoverHide: () => void;
onPopoverAfterHide: () => void;
onToggleWorktree: () => void;
onBaseRefInput: (baseRef: string) => void;
onWorktreeNameInput: (name: string) => void;
onNodeFolderInput: (folder: string, execNode: string) => void;
}) {
const showWorktreeControls = params.state.mode === "git" || params.state.mode === "cloud";
const worktreeEnabled = params.worktreeAvailable || params.worktree;
return html`
<span class="new-session-page__select">
<button
id="new-session-detail-trigger"
type="button"
class="new-session-page__trigger ${params.popoverHiding
? "new-session-page__trigger--hiding"
: ""}"
title=${t("newSession.detail")}
aria-label="${t("newSession.detail")}: ${params.state.label}"
data-worktree=${String(params.worktree)}
aria-haspopup="dialog"
aria-expanded=${String(params.popoverOpen)}
?disabled=${params.submitting || params.pendingCloud}
@click=${params.onGuardTransition}
>
<span class="new-session-page__target-icon" aria-hidden="true"
>${showWorktreeControls ? icons.gitBranch : icons.settings}</span
>
<span class="new-session-page__trigger-label">${params.state.label}</span>
<span class="new-session-page__trigger-chevron" aria-hidden="true"
>${icons.chevronDown}</span
>
</button>
</span>
<wa-popover
class="new-session-page__select new-session-page__detail-popover new-session-page__picker-popover"
for="new-session-detail-trigger"
placement="bottom-start"
without-arrow
@wa-show=${params.onPopoverShow}
@wa-hide=${params.onPopoverHide}
@wa-after-hide=${params.onPopoverAfterHide}
>
<div class="new-session-page__picker-root">
${params.state.mode === "node"
? html`
<label class="new-session-page__menu-field new-session-page__node-path">
<span>${t("newSession.nodeCwd")}</span>
<input
type="text"
?disabled=${params.submitting || params.pendingCloud}
placeholder=${t("newSession.folderPlaceholder")}
.value=${params.folder}
@change=${(event: Event) =>
params.onNodeFolderInput(
(event.target as HTMLInputElement).value.trim(),
params.execNode,
)}
@keydown=${(event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
params.onNodeFolderInput(
(event.target as HTMLInputElement).value.trim(),
params.execNode,
);
}
}}
/>
</label>
`
: showWorktreeControls
? html`
${renderSessionMenuItem(
{
value: "worktree",
label: t("newSession.worktree"),
checked: params.worktree,
disabled: params.state.worktreeLocked || !worktreeEnabled,
title: params.state.worktreeLocked
? t("newSession.cloudRequiresWorktree")
: params.worktreeAvailable
? t("chat.runControls.newSessionWorktree")
: (params.worktreeDisabledReason ?? t("newSession.worktreeUnavailable")),
onSelect: params.onToggleWorktree,
keepOpen: true,
},
params.submitting,
)}
${params.state.worktreeLocked
? html`<div class="new-session-page__menu-note">
${t("newSession.cloudRequiresWorktree")}
</div>`
: nothing}
${params.state.mode === "cloud"
? html`<div class="new-session-page__menu-note">
${t("newSession.cloudSyncsFolder", { folder: params.syncLabel })}
</div>`
: nothing}
${params.worktree
? html`
<label class="new-session-page__menu-field">
<span>${t("newSession.baseBranch")}</span>
<input
type="text"
list="new-session-branches"
?disabled=${params.submitting || params.pendingCloud}
placeholder=${params.branchesLoading
? t("common.loading")
: (params.branches?.defaultBranch ?? t("newSession.baseBranch"))}
.value=${params.baseRef}
@input=${(event: Event) =>
params.onBaseRefInput((event.target as HTMLInputElement).value.trim())}
/>
<datalist id="new-session-branches">
${(params.branches?.branches ?? []).map(
(branch) => html`<option value=${branch.name}></option>`,
)}
</datalist>
</label>
<label class="new-session-page__menu-field">
<span>${t("newSession.worktreeName")}</span>
<input
type="text"
?disabled=${params.submitting || params.pendingCloud}
placeholder=${t("newSession.worktreeNamePlaceholder")}
.value=${params.worktreeName}
@input=${(event: Event) =>
params.onWorktreeNameInput(
(event.target as HTMLInputElement).value.trim(),
)}
/>
</label>
`
: html`<div class="new-session-page__menu-note">
${t("newSession.runsDirectlyNote")}
</div>`}
`
: html`<div class="new-session-page__menu-note">
${params.state.mode === "checking"
? t("newSession.checkingGit")
: params.state.mode === "unavailable"
? t("newSession.gitCheckUnavailable")
: t("newSession.runsDirectlyNote")}
</div>`}
</div>
</wa-popover>
`;
}
+48 -28
View File
@@ -16,10 +16,11 @@ import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gatew
import type { BrowserTarget, DraftNode } from "./discovery.ts";
import type { DraftGatewayState } from "./draft-gateway-state.ts";
import { folderDisplayName, isAbsolutePath, isKnownWorkspacePath } from "./path.ts";
import { projectCloneInput } from "./place-picker.ts";
import { projectCloneInput } from "./project-chip.ts";
import { recentPlaces, type RecentPlaceSource } from "./recent-places.ts";
const PROJECT_SEARCH_DEBOUNCE_MS = 300;
type DraftPickerKind = "where" | "project" | "detail";
type DraftPlaceBrowserSnapshot = Readonly<{
context: ApplicationContext | undefined;
@@ -54,8 +55,8 @@ export class DraftPlaceBrowser {
private browserTargetValue: BrowserTarget | null = null;
private browserProjectPathValue: string | null = null;
private browserRegisteringValue = false;
private placePopoverOpenValue = false;
private placePopoverHidingValue = false;
private openPopoverValue: DraftPickerKind | null = null;
private hidingPopoverValue: DraftPickerKind | null = null;
// Live head input; absolute paths stay applicable even without fs.listDir.
private browserPathDraftValue = "";
private browserRequestToken = 0;
@@ -139,6 +140,13 @@ export class DraftPlaceBrowser {
return this.projectsValue;
}
get projectsReady(): boolean {
return (
this.projectsTask.status === TaskStatus.COMPLETE ||
this.projectsTask.status === TaskStatus.ERROR
);
}
get projectRecents(): readonly ProjectRecent[] | undefined {
return this.projectRecentsValue;
}
@@ -205,12 +213,12 @@ export class DraftPlaceBrowser {
return this.browserRegisteringValue;
}
get placePopoverOpen(): boolean {
return this.placePopoverOpenValue;
popoverOpen(kind: DraftPickerKind): boolean {
return this.openPopoverValue === kind;
}
get placePopoverHiding(): boolean {
return this.placePopoverHidingValue;
popoverHiding(kind: DraftPickerKind): boolean {
return this.hidingPopoverValue === kind;
}
get browserPathDraft(): string {
@@ -376,13 +384,13 @@ export class DraftPlaceBrowser {
close() {
this.resetBrowser(true);
const popover = this.callbacks.querySelector(".new-session-page__place-popover") as
| (HTMLElement & {
open: boolean;
})
| null;
if (popover) {
popover.open = false;
for (const kind of ["where", "project", "detail"] as const) {
const popover = this.callbacks.querySelector(`.new-session-page__${kind}-popover`) as
| (HTMLElement & { open: boolean })
| null;
if (popover) {
popover.open = false;
}
}
}
@@ -528,25 +536,37 @@ export class DraftPlaceBrowser {
}
}
onPopoverShow() {
this.placePopoverOpenValue = true;
this.showRoot();
onPopoverShow(kind: DraftPickerKind) {
this.openPopoverValue = kind;
if (kind === "project") {
this.showRoot();
} else {
this.callbacks.requestUpdate();
}
}
onPopoverHide() {
this.placePopoverOpenValue = false;
this.placePopoverHidingValue = true;
this.showRoot();
onPopoverHide(kind: DraftPickerKind) {
if (this.openPopoverValue === kind) {
this.openPopoverValue = null;
}
this.hidingPopoverValue = kind;
if (kind === "project") {
this.showRoot();
} else {
this.callbacks.requestUpdate();
}
}
onPopoverAfterHide() {
this.placePopoverHidingValue = false;
this.restorePopoverTrigger("new-session-place-trigger", ".new-session-page__place-popover");
onPopoverAfterHide(kind: DraftPickerKind) {
if (this.hidingPopoverValue === kind) {
this.hidingPopoverValue = null;
}
this.restorePopoverTrigger(`new-session-${kind}-trigger`, `.new-session-page__${kind}-popover`);
this.callbacks.requestUpdate();
}
guardPopoverTransition(event: Event) {
if (!this.placePopoverHidingValue) {
guardPopoverTransition(event: Event, kind: DraftPickerKind) {
if (this.hidingPopoverValue !== kind) {
return;
}
event.preventDefault();
@@ -554,7 +574,7 @@ export class DraftPlaceBrowser {
}
clearPopoverHiding() {
this.placePopoverHidingValue = false;
this.hidingPopoverValue = null;
this.callbacks.requestUpdate();
}
@@ -574,7 +594,7 @@ export class DraftPlaceBrowser {
this.browserRegisteringValue = false;
this.browserPathDraftValue = "";
if (closePopover) {
this.placePopoverOpenValue = false;
this.openPopoverValue = null;
}
this.callbacks.requestUpdate();
}
+174 -217
View File
@@ -1,23 +1,22 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type {
FsListDirResult,
WorktreesBranchesResult,
} from "../../../../packages/gateway-protocol/src/index.js";
import type { FsListDirResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { ApplicationContext } from "../../app/context.ts";
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
import { t } from "../../i18n/index.ts";
import { listSelectableAgents } from "../../lib/agents/display.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
import * as catalog from "./catalog-target.ts";
import type { DraftNode, DraftRepositoryState } from "./discovery.ts";
import type { DraftNode } from "./discovery.ts";
import { readDraftNodes } from "./discovery.ts";
import type { DraftGatewayState } from "./draft-gateway-state.ts";
import type { DraftPlaceBrowser } from "./draft-place-browser.ts";
import { DraftRepositoryController } from "./draft-repository-state.ts";
import { isMissingRestoredFolderError } from "./folder-validation.ts";
import type { NewSessionRouteData } from "./location.ts";
import { newSessionSearch } from "./location.ts";
import { NewSessionModelControl } from "./model-control.ts";
import { isKnownWorkspacePath } from "./path.ts";
import type { NewSessionWhere } from "./preferences.ts";
type DraftPlaceSnapshot = Readonly<{
context: ApplicationContext | undefined;
@@ -36,10 +35,6 @@ export class DraftPlaceState {
private agentIdValue = "";
private folderValue = "";
private projectIdValue = "";
private worktreeValue = false;
private worktreeNameValue = "";
private baseRefValue = "";
private repositoryValue: DraftRepositoryState = { kind: "idle" };
private nodesValue: DraftNode[] = [];
private execNodeValue = "";
private cloudProfileIdValue = "";
@@ -50,14 +45,15 @@ export class DraftPlaceState {
private agentSelectedByUser = false;
private folderSelectedByUser = false;
private folderGatewayApproved = false;
private preferredWorktreeRestore = false;
private worktreeSelectedByUser = false;
private preferredWhereRestore: NewSessionWhere | null = null;
private preferredProjectRestore = "";
private whereSelectedByUser = false;
private projectSelectedByUser = false;
private nodesRequestToken = 0;
private branchesRequestToken = 0;
private baseRefEditGeneration = 0;
private restoredFolderValidationToken = 0;
readonly modelControl: NewSessionModelControl;
private readonly repositoryState: DraftRepositoryController;
constructor(
private readonly gateway: DraftGatewayState,
@@ -65,6 +61,21 @@ export class DraftPlaceState {
private readonly read: () => DraftPlaceSnapshot,
private readonly callbacks: DraftPlaceCallbacks,
) {
this.repositoryState = new DraftRepositoryController(
() => ({
execNode: this.execNodeValue,
cloudProfileId: this.cloudProfileIdValue,
selectedProject: this.selectedProject(),
folder: this.folderValue,
workspace: this.workspacePath(),
workspaceGit: this.selectedAgent()?.workspaceGit === true,
gateway: this.read().context?.gateway.snapshot,
}),
{
requestUpdate: callbacks.requestUpdate,
persistPreference: (patch) => this.persistPreference(patch),
},
);
this.modelControl = new NewSessionModelControl(
callbacks.requestUpdate,
(selection) => this.persistPreference(selection),
@@ -88,19 +99,19 @@ export class DraftPlaceState {
}
get worktree(): boolean {
return this.worktreeValue;
return this.repositoryState.worktree;
}
get worktreeName(): string {
return this.worktreeNameValue;
return this.repositoryState.worktreeName;
}
get baseRef(): string {
return this.baseRefValue;
return this.repositoryState.baseRef;
}
get repository(): DraftRepositoryState {
return this.repositoryValue;
get repository() {
return this.repositoryState.repository;
}
get nodes(): readonly DraftNode[] {
@@ -120,7 +131,7 @@ export class DraftPlaceState {
}
get worktreePreferenceReady(): boolean {
return !this.preferredWorktreeRestore;
return this.repositoryState.preferenceReady;
}
setAgentsHydrated(value: boolean) {
@@ -237,8 +248,12 @@ export class DraftPlaceState {
this.folderValue = storedFolderUsable ? storedFolder : workspace;
this.folderGatewayApproved = false;
this.folderSelectedByUser = false;
this.preferredWorktreeRestore = preference?.worktree === true;
this.worktreeSelectedByUser = false;
this.repositoryState.adoptPreference(preference);
const preferredWhere = preference?.where ?? { kind: "local" };
this.preferredWhereRestore = preferredWhere.kind === "local" ? null : preferredWhere;
this.preferredProjectRestore = preference?.projectId ?? "";
this.whereSelectedByUser = false;
this.projectSelectedByUser = false;
if (storedWorkspaceMoved) {
this.persistPreference({ folder: workspace });
}
@@ -249,14 +264,16 @@ export class DraftPlaceState {
!snapshot.pendingCloudSessionKey &&
this.agentIdValue
) {
this.persistPreference({ folder: this.folderValue, worktree: this.worktreeValue });
this.persistPreference({ folder: this.folderValue, worktree: this.worktree });
}
void this.loadNodes();
this.modelControl.load(snapshot.context, this.agentIdValue, !catalog.isTarget(snapshot.data), {
agent: this.selectedAgent(),
preference,
});
if (
if (this.preferredProjectRestore) {
this.cancelRestoredFolderValidation();
} else if (
!this.folderSelectedByUser &&
this.folderValue !== this.workspacePath() &&
!this.execNodeValue &&
@@ -265,7 +282,7 @@ export class DraftPlaceState {
this.validateRestoredFolder(this.folderValue);
} else {
this.cancelRestoredFolderValidation();
this.maybeLoadBranches();
this.repositoryState.load();
}
this.callbacks.requestUpdate();
}
@@ -279,12 +296,11 @@ export class DraftPlaceState {
this.folderGatewayApproved = false;
this.gatewayApprovedWorkspaceRoots = [];
this.cancelRestoredFolderValidation();
this.preferredWorktreeRestore = false;
this.worktreeSelectedByUser = false;
this.worktreeValue = false;
this.worktreeNameValue = "";
this.baseRefValue = "";
this.repositoryValue = { kind: "idle" };
this.preferredWhereRestore = null;
this.preferredProjectRestore = "";
this.whereSelectedByUser = false;
this.projectSelectedByUser = false;
this.repositoryState.reset();
this.execNodeValue = "";
this.modelControl.reset();
this.cloudProfileIdValue = "";
@@ -294,9 +310,7 @@ export class DraftPlaceState {
invalidateGatewayDiscovery(resetHostSelection: boolean) {
this.nodesRequestToken += 1;
this.nodesHydrated = false;
this.branchesRequestToken += 1;
this.repositoryValue = { kind: "idle" };
this.baseRefValue = "";
this.repositoryState.invalidate();
this.agentsHydratedValue = false;
this.modelControl.invalidate(resetHostSelection);
this.browser.close();
@@ -314,11 +328,11 @@ export class DraftPlaceState {
this.browser.resetProjects();
this.projectIdValue = "";
this.folderSelectedByUser = false;
this.preferredWorktreeRestore = false;
this.worktreeSelectedByUser = false;
this.worktreeValue = false;
this.worktreeNameValue = "";
this.baseRefEditGeneration += 1;
this.preferredWhereRestore = null;
this.preferredProjectRestore = "";
this.whereSelectedByUser = false;
this.projectSelectedByUser = false;
this.repositoryState.reset();
this.nodesValue = [];
this.execNodeValue = "";
this.cloudProfileIdValue = "";
@@ -328,7 +342,7 @@ export class DraftPlaceState {
applyPendingCloud(params: { agentId: string; profileId: string; cwd?: string }) {
this.agentIdValue = params.agentId;
this.cloudProfileIdValue = params.profileId;
this.worktreeValue = true;
this.repositoryState.forceWorktree(true);
this.folderValue = params.cwd ?? "";
this.folderGatewayApproved = false;
this.callbacks.requestUpdate();
@@ -342,7 +356,7 @@ export class DraftPlaceState {
clearProjectSelection() {
this.projectIdValue = "";
this.maybeLoadBranches();
this.repositoryState.load();
this.callbacks.requestUpdate();
}
@@ -363,11 +377,12 @@ export class DraftPlaceState {
this.folderGatewayApproved = false;
this.gatewayApprovedWorkspaceRoots = [];
this.projectIdValue = "";
this.preferredWorktreeRestore = false;
this.worktreeSelectedByUser = false;
this.preferredWhereRestore = null;
this.preferredProjectRestore = "";
this.whereSelectedByUser = false;
this.projectSelectedByUser = false;
this.cloudProfileIdValue = "";
this.worktreeValue = false;
this.worktreeNameValue = "";
this.repositoryState.reset();
this.browser.close();
if (this.execNodeValue) {
this.folderValue = "";
@@ -390,16 +405,24 @@ export class DraftPlaceState {
this.folderValue = folder.trim();
this.folderGatewayApproved = gatewayApproved && !execNode && !this.isAdmin();
this.folderSelectedByUser = true;
this.preferredWorktreeRestore = false;
this.worktreeSelectedByUser = true;
if (this.execNodeValue || !this.cloudProfileIdValue) {
this.worktreeValue = false;
}
this.worktreeNameValue = "";
this.projectSelectedByUser = true;
this.preferredProjectRestore = "";
this.repositoryState.selectWorktree(!this.execNodeValue && Boolean(this.cloudProfileIdValue));
if (!this.execNodeValue && this.agentsHydratedValue) {
this.persistPreference({ folder: this.folderValue, worktree: this.worktreeValue });
this.persistPreference({
folder: this.folderValue,
projectId: "",
worktree: this.worktree,
});
} else if (this.execNodeValue && this.agentsHydratedValue) {
this.persistPreference({
folder: this.folderValue,
projectId: "",
where: { kind: "node", id: this.execNodeValue },
worktree: false,
});
}
this.maybeLoadBranches();
this.repositoryState.load();
}
selectProjectId(projectId: string) {
@@ -415,14 +438,20 @@ export class DraftPlaceState {
this.browser.resetProjectSearch();
this.projectIdValue = project.id;
this.execNodeValue = "";
this.cloudProfileIdValue = "";
this.callbacks.onError(null);
this.folderSelectedByUser = false;
this.preferredWorktreeRestore = false;
this.worktreeSelectedByUser = true;
this.worktreeValue = false;
this.worktreeNameValue = "";
this.maybeLoadBranches();
this.projectSelectedByUser = true;
this.preferredProjectRestore = "";
this.repositoryState.selectWorktree(Boolean(this.cloudProfileIdValue));
this.persistPreference({
projectId: project.id,
where: this.cloudProfileIdValue
? { kind: "cloud", id: this.cloudProfileIdValue }
: { kind: "local" },
worktree: this.worktree,
worktreeName: "",
});
this.repositoryState.load();
}
selectExecNode(execNode: string) {
@@ -435,19 +464,28 @@ export class DraftPlaceState {
}
const keepGatewayFolder = !execNode && !this.execNodeValue;
this.cancelRestoredFolderValidation();
const keepWorktree = keepGatewayFolder && this.worktreeValue && this.worktreeAvailable();
const keepWorktree = keepGatewayFolder && this.worktree && this.worktreeAvailable();
this.execNodeValue = execNode;
this.cloudProfileIdValue = "";
this.whereSelectedByUser = true;
this.preferredWhereRestore = null;
if (!keepGatewayFolder) {
this.folderValue = execNode ? "" : this.workspacePath();
this.folderSelectedByUser = false;
this.folderGatewayApproved = false;
this.projectIdValue = "";
this.projectSelectedByUser = true;
}
this.worktreeValue = keepWorktree;
this.repositoryState.selectWorktree(keepWorktree, false);
this.persistPreference({
where: execNode ? { kind: "node", id: execNode } : { kind: "local" },
projectId: this.projectIdValue,
folder: this.folderValue,
worktree: this.worktree,
});
this.browser.close();
if (!this.branchesMatchCurrentRepo()) {
this.maybeLoadBranches();
if (!this.repositoryState.matchesCurrentRepo()) {
this.repositoryState.load();
}
this.callbacks.requestUpdate();
}
@@ -463,46 +501,88 @@ export class DraftPlaceState {
return;
}
this.cloudProfileIdValue = profileId;
this.projectIdValue = "";
this.whereSelectedByUser = true;
this.preferredWhereRestore = null;
this.callbacks.onError(null);
this.worktreeValue = true;
this.repositoryState.forceWorktree(true);
this.persistPreference({
where: { kind: "cloud", id: profileId },
projectId: this.projectIdValue,
worktree: true,
});
this.browser.close();
if (!this.branchesMatchCurrentRepo()) {
this.maybeLoadBranches();
if (!this.repositoryState.matchesCurrentRepo()) {
this.repositoryState.load();
}
this.callbacks.requestUpdate();
}
toggleWorktree() {
if (this.cloudProfileIdValue) {
return;
}
this.worktreeValue = !this.worktreeValue;
this.preferredWorktreeRestore = false;
this.worktreeSelectedByUser = true;
this.persistPreference({
folder: this.folderValue.trim() || this.workspacePath(),
worktree: this.worktreeValue,
});
if (this.worktreeValue && this.repositoryValue.kind !== "git") {
this.maybeLoadBranches();
}
this.callbacks.requestUpdate();
this.repositoryState.toggle();
}
setBaseRef(baseRef: string) {
if (!this.read().submitting) {
this.baseRefEditGeneration += 1;
this.baseRefValue = baseRef;
this.callbacks.requestUpdate();
}
this.repositoryState.setBaseRef(baseRef, this.read().submitting);
}
setWorktreeName(worktreeName: string) {
if (!this.read().submitting) {
this.worktreeNameValue = worktreeName;
this.callbacks.requestUpdate();
this.repositoryState.setWorktreeName(worktreeName, this.read().submitting);
}
restorePreferenceSelections() {
let changed = false;
const preferredWhere = this.whereSelectedByUser ? null : this.preferredWhereRestore;
let preferredProject = this.projectSelectedByUser ? "" : this.preferredProjectRestore;
if (preferredWhere?.kind !== "node" && preferredProject) {
const project = this.browser.selectedProject(preferredProject);
if (project) {
this.projectIdValue = project.id;
this.execNodeValue = "";
this.folderSelectedByUser = false;
this.preferredProjectRestore = "";
changed = true;
} else if (this.browser.projectsReady) {
this.preferredProjectRestore = "";
preferredProject = "";
changed = true;
}
}
if (preferredWhere?.kind === "node" && this.nodesHydrated) {
const nodeAvailable = this.execNodes().some((node) => node.nodeId === preferredWhere.id);
this.execNodeValue = nodeAvailable ? preferredWhere.id : "";
this.cloudProfileIdValue = "";
this.projectIdValue = "";
this.repositoryState.forceWorktree(false);
this.preferredWhereRestore = null;
this.preferredProjectRestore = "";
changed = true;
} else if (preferredWhere?.kind === "cloud" && this.gateway.cloudProfilesReady) {
const profileAvailable = this.gateway.cloudProfiles.some(
(profile) => profile.id === preferredWhere.id,
);
const projectReady = !preferredProject || this.projectIdValue === preferredProject;
if (profileAvailable && projectReady && this.worktreeAvailable()) {
this.execNodeValue = "";
this.cloudProfileIdValue = preferredWhere.id;
this.repositoryState.forceWorktree(true);
this.preferredWhereRestore = null;
changed = true;
} else if (!profileAvailable) {
if (this.cloudProfileIdValue !== preferredWhere.id) {
this.cloudProfileIdValue = "";
changed = true;
}
this.preferredWhereRestore = null;
}
}
if (!changed) {
return;
}
this.repositoryState.load();
this.callbacks.requestUpdate();
}
browseAvailable(): boolean {
@@ -510,20 +590,7 @@ export class DraftPlaceState {
}
worktreeAvailable(): boolean {
if (this.execNodeValue) {
return false;
}
if (this.selectedProject()?.repoRoot) {
return true;
}
if (this.repositoryValue.kind === "git") {
return true;
}
return (
this.repositoryValue.kind === "unavailable" &&
this.repositoryValue.repoRoot === this.workspacePath() &&
this.selectedAgent()?.workspaceGit === true
);
return this.repositoryState.available();
}
private usesCustomFolder(): boolean {
@@ -548,10 +615,9 @@ export class DraftPlaceState {
this.folderGatewayApproved = false;
this.callbacks.onClearError(t("newSession.browserLoadFailed"));
this.folderValue = this.workspacePath();
this.worktreeValue = false;
this.preferredWorktreeRestore = false;
this.repositoryState.rejectPreferredWorktree();
this.persistPreference({ folder: this.folderValue, worktree: false });
this.maybeLoadBranches();
this.repositoryState.load();
}
private validateRestoredFolder(folder: string) {
@@ -577,7 +643,7 @@ export class DraftPlaceState {
this.folderGatewayApproved = !this.isAdmin();
this.restoredFolderValidation = "none";
this.callbacks.onClearError(t("newSession.browserLoadFailed"));
this.maybeLoadBranches();
this.repositoryState.load();
})
.catch((error: unknown) => {
if (
@@ -625,10 +691,9 @@ export class DraftPlaceState {
this.folderValue = this.workspacePath();
this.folderSelectedByUser = false;
this.folderGatewayApproved = false;
this.worktreeValue = false;
this.worktreeNameValue = "";
this.repositoryState.selectWorktree(false);
this.browser.close();
this.maybeLoadBranches();
this.repositoryState.load();
}
this.callbacks.requestUpdate();
} catch {
@@ -639,112 +704,4 @@ export class DraftPlaceState {
}
}
}
private maybeLoadBranches() {
const requestId = ++this.branchesRequestToken;
const restoreWorktree = this.preferredWorktreeRestore && !this.worktreeSelectedByUser;
const baseRefEditGeneration = this.baseRefEditGeneration;
this.repositoryValue = { kind: "idle" };
this.baseRefValue = "";
const selectedProject = this.selectedProject();
if (this.execNodeValue) {
this.preferredWorktreeRestore = false;
return;
}
if (selectedProject && !selectedProject.repoRoot) {
this.preferredWorktreeRestore = false;
return;
}
const repoRoot = selectedProject?.repoRoot ?? (this.folderValue.trim() || this.workspacePath());
const agent = this.selectedAgent();
const usesWorkspace = !selectedProject && repoRoot === this.workspacePath();
if (!repoRoot) {
this.preferredWorktreeRestore = false;
return;
}
if (usesWorkspace && agent?.workspaceGit !== true) {
this.repositoryValue = { kind: "direct", repoRoot };
const rejectedWorktree = !this.cloudProfileIdValue && (this.worktreeValue || restoreWorktree);
if (!this.cloudProfileIdValue) {
this.worktreeValue = false;
}
this.preferredWorktreeRestore = false;
if (rejectedWorktree) {
this.persistPreference({ worktree: false });
}
return;
}
const snapshot = this.read().context?.gateway.snapshot;
const client = snapshot?.client;
if (snapshot?.phase !== "connected" || !client) {
this.preferredWorktreeRestore = false;
return;
}
this.repositoryValue = { kind: "checking", repoRoot };
void client
.request<WorktreesBranchesResult>("worktrees.branches", {
repoRoot,
includeRepositoryStatus: true,
})
.then((result) => {
if (requestId !== this.branchesRequestToken) {
return;
}
if (result?.repositoryStatus !== "git") {
this.repositoryValue = {
kind: result?.repositoryStatus === "not_git" ? "direct" : "unavailable",
repoRoot,
};
if (result?.repositoryStatus === "not_git") {
const rejectedWorktree =
!this.cloudProfileIdValue && (this.worktreeValue || restoreWorktree);
if (!this.cloudProfileIdValue) {
this.worktreeValue = false;
}
if (rejectedWorktree) {
this.persistPreference({ worktree: false });
}
} else if (restoreWorktree && !this.worktreeSelectedByUser && this.worktreeAvailable()) {
this.worktreeValue = true;
}
this.preferredWorktreeRestore = false;
this.callbacks.requestUpdate();
return;
}
this.repositoryValue = {
kind: "git",
repoRoot,
branches: result.branches,
...(result.defaultBranch ? { defaultBranch: result.defaultBranch } : {}),
...(result.headBranch ? { headBranch: result.headBranch } : {}),
};
if (restoreWorktree && !this.worktreeSelectedByUser && !this.execNodeValue) {
this.worktreeValue = true;
}
this.preferredWorktreeRestore = false;
if (baseRefEditGeneration === this.baseRefEditGeneration) {
this.baseRefValue = result.defaultBranch ?? result.headBranch ?? "";
}
this.callbacks.requestUpdate();
})
.catch(() => {
if (requestId !== this.branchesRequestToken) {
return;
}
this.repositoryValue = { kind: "unavailable", repoRoot };
if (restoreWorktree && !this.worktreeSelectedByUser && this.worktreeAvailable()) {
this.worktreeValue = true;
}
this.preferredWorktreeRestore = false;
this.callbacks.requestUpdate();
});
}
private branchesMatchCurrentRepo(): boolean {
if (this.execNodeValue || this.repositoryValue.kind === "idle") {
return false;
}
const repoRoot = this.folderValue.trim() || this.workspacePath();
return this.repositoryValue.repoRoot === repoRoot;
}
}
@@ -0,0 +1,266 @@
import type {
ProjectRecord,
WorktreesBranchesResult,
} from "../../../../packages/gateway-protocol/src/index.js";
import type { ApplicationContext } from "../../app/context.ts";
import type { DraftRepositoryState } from "./discovery.ts";
import type { NewSessionPreference } from "./preferences.ts";
type DraftRepositorySnapshot = Readonly<{
execNode: string;
cloudProfileId: string;
selectedProject: ProjectRecord | undefined;
folder: string;
workspace: string;
workspaceGit: boolean;
gateway: ApplicationContext["gateway"]["snapshot"] | undefined;
}>;
type DraftRepositoryCallbacks = {
requestUpdate: () => void;
persistPreference: (patch: NewSessionPreference) => void;
};
export class DraftRepositoryController {
private worktreeValue = false;
private worktreeNameValue = "";
private baseRefValue = "";
private repositoryValue: DraftRepositoryState = { kind: "idle" };
private requestToken = 0;
private baseRefEditGeneration = 0;
private preferredWorktreeRestore = false;
private preferredBaseRefRestore = "";
private worktreeSelectedByUser = false;
constructor(
private readonly read: () => DraftRepositorySnapshot,
private readonly callbacks: DraftRepositoryCallbacks,
) {}
get worktree(): boolean {
return this.worktreeValue;
}
get worktreeName(): string {
return this.worktreeNameValue;
}
get baseRef(): string {
return this.baseRefValue;
}
get repository(): DraftRepositoryState {
return this.repositoryValue;
}
get preferenceReady(): boolean {
return !this.preferredWorktreeRestore;
}
adoptPreference(preference: NewSessionPreference | null) {
this.preferredWorktreeRestore = preference?.worktree === true;
this.preferredBaseRefRestore = preference?.baseRef ?? "";
this.worktreeNameValue = preference?.worktreeName ?? "";
this.worktreeSelectedByUser = false;
}
reset() {
this.requestToken += 1;
this.baseRefEditGeneration += 1;
this.worktreeValue = false;
this.worktreeNameValue = "";
this.baseRefValue = "";
this.repositoryValue = { kind: "idle" };
this.preferredWorktreeRestore = false;
this.preferredBaseRefRestore = "";
this.worktreeSelectedByUser = false;
}
invalidate() {
this.requestToken += 1;
this.repositoryValue = { kind: "idle" };
this.baseRefValue = "";
}
selectWorktree(value: boolean, clearName = true) {
this.preferredWorktreeRestore = false;
this.worktreeSelectedByUser = true;
this.worktreeValue = value;
if (clearName) {
this.worktreeNameValue = "";
}
}
forceWorktree(value: boolean) {
this.worktreeValue = value;
}
rejectPreferredWorktree() {
this.preferredWorktreeRestore = false;
this.worktreeValue = false;
}
toggle() {
if (this.read().cloudProfileId) {
return;
}
this.worktreeValue = !this.worktreeValue;
this.preferredWorktreeRestore = false;
this.worktreeSelectedByUser = true;
this.callbacks.persistPreference({
folder: this.read().folder.trim() || this.read().workspace,
worktree: this.worktreeValue,
});
if (this.worktreeValue && this.repositoryValue.kind !== "git") {
this.load();
}
this.callbacks.requestUpdate();
}
setBaseRef(baseRef: string, submitting: boolean) {
if (submitting) {
return;
}
this.baseRefEditGeneration += 1;
this.baseRefValue = baseRef;
this.preferredBaseRefRestore = "";
this.callbacks.persistPreference({ baseRef });
this.callbacks.requestUpdate();
}
setWorktreeName(worktreeName: string, submitting: boolean) {
if (submitting) {
return;
}
this.worktreeNameValue = worktreeName;
this.callbacks.persistPreference({ worktreeName });
this.callbacks.requestUpdate();
}
available(): boolean {
const snapshot = this.read();
if (snapshot.execNode) {
return false;
}
if (snapshot.selectedProject?.repoRoot) {
return true;
}
if (this.repositoryValue.kind === "git") {
return true;
}
return (
this.repositoryValue.kind === "unavailable" &&
this.repositoryValue.repoRoot === snapshot.workspace &&
snapshot.workspaceGit
);
}
matchesCurrentRepo(): boolean {
if (this.read().execNode || this.repositoryValue.kind === "idle") {
return false;
}
const snapshot = this.read();
const repoRoot =
snapshot.selectedProject?.repoRoot ?? (snapshot.folder.trim() || snapshot.workspace);
return this.repositoryValue.repoRoot === repoRoot;
}
load() {
const requestId = ++this.requestToken;
const restoreWorktree = this.preferredWorktreeRestore && !this.worktreeSelectedByUser;
const restoreBaseRef = this.preferredBaseRefRestore;
const baseRefEditGeneration = this.baseRefEditGeneration;
const snapshot = this.read();
this.repositoryValue = { kind: "idle" };
this.baseRefValue = "";
if (snapshot.execNode || (snapshot.selectedProject && !snapshot.selectedProject.repoRoot)) {
this.preferredWorktreeRestore = false;
return;
}
const repoRoot =
snapshot.selectedProject?.repoRoot ?? (snapshot.folder.trim() || snapshot.workspace);
const usesWorkspace = !snapshot.selectedProject && repoRoot === snapshot.workspace;
if (!repoRoot) {
this.preferredWorktreeRestore = false;
return;
}
if (usesWorkspace && !snapshot.workspaceGit) {
this.repositoryValue = { kind: "direct", repoRoot };
const rejectedWorktree = !snapshot.cloudProfileId && (this.worktreeValue || restoreWorktree);
if (!snapshot.cloudProfileId) {
this.worktreeValue = false;
}
this.preferredWorktreeRestore = false;
if (rejectedWorktree) {
this.callbacks.persistPreference({ worktree: false });
}
return;
}
const client = snapshot.gateway?.client;
if (snapshot.gateway?.phase !== "connected" || !client) {
this.preferredWorktreeRestore = false;
return;
}
this.repositoryValue = { kind: "checking", repoRoot };
void client
.request<WorktreesBranchesResult>("worktrees.branches", {
repoRoot,
includeRepositoryStatus: true,
})
.then((result) => {
if (requestId !== this.requestToken) {
return;
}
if (result?.repositoryStatus !== "git") {
this.repositoryValue = {
kind: result?.repositoryStatus === "not_git" ? "direct" : "unavailable",
repoRoot,
};
if (result?.repositoryStatus === "not_git") {
const rejectedWorktree =
!this.read().cloudProfileId && (this.worktreeValue || restoreWorktree);
if (!this.read().cloudProfileId) {
this.worktreeValue = false;
}
if (rejectedWorktree) {
this.callbacks.persistPreference({ worktree: false });
}
} else if (restoreWorktree && !this.worktreeSelectedByUser && this.available()) {
this.worktreeValue = true;
}
this.preferredWorktreeRestore = false;
this.callbacks.requestUpdate();
return;
}
this.repositoryValue = {
kind: "git",
repoRoot,
branches: result.branches,
...(result.defaultBranch ? { defaultBranch: result.defaultBranch } : {}),
...(result.headBranch ? { headBranch: result.headBranch } : {}),
};
if (restoreWorktree && !this.worktreeSelectedByUser && !this.read().execNode) {
this.worktreeValue = true;
}
this.preferredWorktreeRestore = false;
if (baseRefEditGeneration === this.baseRefEditGeneration) {
this.baseRefValue = restoreBaseRef || result.defaultBranch || result.headBranch || "";
if (restoreBaseRef) {
this.preferredBaseRefRestore = "";
}
}
this.callbacks.requestUpdate();
})
.catch(() => {
if (requestId !== this.requestToken) {
return;
}
this.repositoryValue = { kind: "unavailable", repoRoot };
if (restoreWorktree && !this.worktreeSelectedByUser && this.available()) {
this.worktreeValue = true;
}
this.preferredWorktreeRestore = false;
this.callbacks.requestUpdate();
});
}
}
+111 -107
View File
@@ -1,7 +1,6 @@
import { consume } from "@lit/context";
import { html, nothing, type ReactiveController, type ReactiveControllerHost } from "lit";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import type { PresenceEntry } from "../../api/types.ts";
import { selectApplicationSession } from "../../app/agent-selection.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { beginNativeWindowDragFromTopInset } from "../../app/native-window-drag.ts";
@@ -24,42 +23,22 @@ import type { SubmissionOutcomeReason } from "./cloud-recovery-state.ts";
import { renderDraftError, renderNewSessionDraftComposer } from "./composer.ts";
import { renderConnectMachineDialog } from "./connect-machine-dialog.ts";
import { isWorktreeNameValid } from "./create-params.ts";
import { renderDetailChip, resolveDetailChip } from "./detail-chip.ts";
import { DraftGatewayState } from "./draft-gateway-state.ts";
import { DraftPlaceBrowser } from "./draft-place-browser.ts";
import { DraftPlaceState } from "./draft-place-state.ts";
import { DraftSubmissionFlow } from "./draft-submission-flow.ts";
import type { NewSessionRouteData } from "./location.ts";
import { renderPlaceSelect } from "./place-picker.ts";
import {
closeAgentPicker,
closeSessionMenus,
createControllerHost,
presenceStateSignature,
readPresenceEntries,
} from "./new-session-runtime.ts";
import { renderProjectChip, resolveProjectChip } from "./project-chip.ts";
import { renderAgentSelect } from "./target-controls.ts";
function readPresence(value: unknown): PresenceEntry[] | null {
const presence =
value && typeof value === "object" ? (value as { presence?: unknown }).presence : null;
return Array.isArray(presence) ? (presence as PresenceEntry[]) : null;
}
function presenceConnectivitySignature(entries: PresenceEntry[]): string {
const states = new Map<string, "connected" | "offline">();
for (const entry of entries) {
const id = (entry.deviceId ?? entry.instanceId)?.trim().toLowerCase();
if (!id || entry.mode?.trim().toLowerCase() === "gateway") {
continue;
}
states.set(id, entry.reason?.trim().toLowerCase() === "disconnect" ? "offline" : "connected");
}
return JSON.stringify([...states].toSorted(([left], [right]) => left.localeCompare(right)));
}
function controllerHost(element: OpenClawLightDomElement): ReactiveControllerHost {
return {
addController: (controller: ReactiveController) => element.addController(controller),
removeController: (controller: ReactiveController) => element.removeController(controller),
requestUpdate: () => element.requestUpdate(),
get updateComplete() {
return element.updateComplete;
},
};
}
import { renderWhereChip, resolveWhereChip } from "./where-chip.ts";
class NewSessionPage extends OpenClawLightDomElement {
@property({ attribute: false }) data: NewSessionRouteData | undefined;
@@ -84,7 +63,7 @@ class NewSessionPage extends OpenClawLightDomElement {
constructor() {
super();
const host = controllerHost(this);
const host = createControllerHost(this);
this.gateway = new DraftGatewayState(
host,
() => ({
@@ -165,7 +144,7 @@ class NewSessionPage extends OpenClawLightDomElement {
() => ({ context: this.context, data: this.data, isConnected: this.isConnected }),
{
requestUpdate: () => this.requestUpdate(),
closeTransientUi: () => this.closeOpenDropdowns(),
closeTransientUi: () => closeSessionMenus(this),
},
);
this.subscriptions = new SubscriptionsController(this)
@@ -177,8 +156,8 @@ class NewSessionPage extends OpenClawLightDomElement {
.effect(
() => this.context?.gateway,
(gateway) => {
this.presenceSignature = presenceConnectivitySignature(
readPresence(gateway.snapshot.hello?.snapshot) ?? [],
this.presenceSignature = presenceStateSignature(
readPresenceEntries(gateway.snapshot.hello?.snapshot) ?? [],
);
return gateway.subscribeEvents((event) => {
if (this.context?.gateway !== gateway) {
@@ -194,11 +173,11 @@ class NewSessionPage extends OpenClawLightDomElement {
this.refreshPlaceTopology();
return;
}
const presence = event.event === "presence" ? readPresence(event.payload) : null;
const presence = event.event === "presence" ? readPresenceEntries(event.payload) : null;
if (!presence) {
return;
}
const signature = presenceConnectivitySignature(presence);
const signature = presenceStateSignature(presence);
if (signature !== this.presenceSignature) {
this.presenceSignature = signature;
this.refreshPlaceTopology();
@@ -324,6 +303,7 @@ class NewSessionPage extends OpenClawLightDomElement {
preserveSelectedFolder: true,
});
}
this.place.restorePreferenceSelections();
}
private invalidateGatewayDiscovery(
@@ -347,7 +327,7 @@ class NewSessionPage extends OpenClawLightDomElement {
this.submission.resetDraft();
this.messageOwnerKey = catalog.routeKey(this.data);
this.browser.clearPopoverHiding();
this.closeAgentDropdown();
closeAgentPicker(this);
this.browser.close();
this.closeConnectMachine();
this.place.adoptAgentDefaults();
@@ -365,23 +345,6 @@ class NewSessionPage extends OpenClawLightDomElement {
this.setMessage(message, catalog.routeKeyFromSearch(window.location.search));
}
private closeAgentDropdown() {
const dropdown = this.querySelector<HTMLElement & { open: boolean }>(
".new-session-page__select--agent wa-dropdown",
);
if (dropdown) {
dropdown.open = false;
}
}
private closeOpenDropdowns() {
for (const dropdown of this.querySelectorAll<HTMLElement & { open: boolean }>(
"wa-dropdown[open]",
)) {
dropdown.open = false;
}
}
private renderAgentSelect() {
return renderAgentSelect({
agents: this.place.agents(),
@@ -396,32 +359,83 @@ class NewSessionPage extends OpenClawLightDomElement {
return catalog.renderBar({
data: this.data,
agentSelect: agents.length > 1 ? this.renderAgentSelect() : nothing,
placeSelect: this.renderPlaceSelect(),
placeSelect: this.renderPlaceChips(),
retrying: this.gateway.catalogRetrying,
onRetry: this.gateway.handleCatalogRetry,
});
}
private renderPlaceSelect() {
private renderPlaceChips() {
const execNodes = this.place.execNodes();
const cloudProfiles = catalog.isTarget(this.data) ? [] : this.gateway.cloudProfiles;
const branches = this.place.repository.kind === "git" ? this.place.repository : null;
return renderPlaceSelect({
const projects = catalog.isTarget(this.data) ? [] : this.browser.projects;
const recents = catalog.isTarget(this.data)
? []
: this.browser.resolveProjectRecents({
sessions: this.context?.sessions.state.result?.sessions ?? [],
workspace: this.place.workspacePath(),
workspaceRoots: this.place.knownWorkspaceRoots(),
execNodes,
isAdmin: this.place.isAdmin(),
});
const whereState = resolveWhereChip({
execNodes: this.place.isAdmin() ? execNodes : [],
environments: this.place.isAdmin() ? this.gateway.environments : [],
cloudProfiles: this.place.isAdmin() ? cloudProfiles : [],
cloudProfileId: this.place.cloudProfileId,
execNode: this.place.execNode,
});
const projectState = resolveProjectChip({
folder: this.place.folder,
workspace: this.place.workspacePath(),
projectId: this.place.projectId,
projects,
recents,
projectQuery: this.browser.projectQuery,
execNode: this.place.execNode,
});
const detailState = resolveDetailChip({
execNode: this.place.execNode,
cloudProfileId: this.place.cloudProfileId,
worktree: this.place.worktree,
repository: this.place.repository,
});
const gatewayLabel = this.gateway.gatewayName
? t("newSession.gatewayNamed", { name: this.gateway.gatewayName })
: t("newSession.gateway");
const commonPopover = (kind: "where" | "project" | "detail") => ({
popoverOpen: this.browser.popoverOpen(kind),
popoverHiding: this.browser.popoverHiding(kind),
onGuardTransition: (event: MouseEvent) => this.browser.guardPopoverTransition(event, kind),
onPopoverShow: () => this.browser.onPopoverShow(kind),
onPopoverHide: () => this.browser.onPopoverHide(kind),
onPopoverAfterHide: () => this.browser.onPopoverAfterHide(kind),
});
const submitting = this.submission.submitting || this.browser.projectCloneBusy;
const pendingCloud = Boolean(this.submission.pendingCloud.sessionKey);
return html`${renderWhereChip({
state: whereState,
gatewayName: this.gateway.gatewayName,
cloudProfileId: this.place.cloudProfileId,
execNode: this.place.execNode,
worktreeAvailable: this.place.worktreeAvailable(),
cloudDisabledReason: this.submission.cloudDisabledReason(),
submitting,
pendingCloud,
isAdmin: this.place.isAdmin(),
...commonPopover("where"),
onSelectExecNode: (nodeId) => this.place.selectExecNode(nodeId),
onSelectCloudProfile: (profileId) => this.place.selectCloudProfile(profileId),
onConnectMachine: () => this.openConnectMachine(),
})}${renderProjectChip({
state: projectState,
browseAvailable: this.place.browseAvailable(),
isAdmin: this.place.isAdmin(),
canWrite: this.place.canWrite(),
folder: this.place.folder,
workspace: this.place.workspacePath(),
projects: catalog.isTarget(this.data) ? [] : this.browser.projects,
recents: catalog.isTarget(this.data)
? []
: this.browser.resolveProjectRecents({
sessions: this.context?.sessions.state.result?.sessions ?? [],
workspace: this.place.workspacePath(),
workspaceRoots: this.place.knownWorkspaceRoots(),
execNodes,
isAdmin: this.place.isAdmin(),
}),
projects,
projectQuery: this.browser.projectQuery,
projectSearchAvailable: canCallGatewayMethod(
this.context?.gateway.snapshot,
@@ -434,42 +448,18 @@ class NewSessionPage extends OpenClawLightDomElement {
"operator.write",
),
remoteProjects: this.browser.projectSearchResult?.projects ?? [],
projectSearchCredential: this.browser.projectSearchResult?.credential ?? null,
projectSearchCredentialMissing: this.browser.projectSearchResult?.credential === "missing",
projectSearchLoading: this.browser.projectSearchLoading,
projectSearchError: this.browser.projectSearchError,
projectCloneBusy: this.browser.projectCloneBusy,
projectCloneError: this.browser.projectCloneError,
projectId: this.place.projectId,
execNodes: this.place.isAdmin() ? execNodes : [],
environments: this.place.isAdmin() ? this.gateway.environments : [],
gatewayName: this.gateway.gatewayName,
cloudProfiles: this.place.isAdmin() ? cloudProfiles : [],
cloudProfileId: this.place.cloudProfileId,
execNodes,
gatewayLabel,
execNode: this.place.execNode,
syncFolder: this.place.folder.trim() || this.place.workspacePath(),
worktree: this.place.worktree,
worktreeVisible:
this.place.worktreeAvailable() || Boolean(this.place.cloudProfileId) || this.place.worktree,
worktreeAvailable: this.place.worktreeAvailable(),
worktreeDisabledReason:
this.place.repository.kind === "checking"
? t("newSession.checkingGit")
: this.place.repository.kind === "unavailable"
? t("newSession.gitCheckUnavailable")
: undefined,
cloudDisabledReason: this.submission.cloudDisabledReason(),
branches,
branchesLoading: this.place.repository.kind === "checking",
baseRef: this.place.baseRef,
worktreeName: this.place.worktreeName,
submitting: this.submission.submitting || this.browser.projectCloneBusy,
pendingCloud: Boolean(this.submission.pendingCloud.sessionKey),
showDestinations:
Boolean(this.place.execNode) ||
Boolean(this.place.cloudProfileId) ||
(this.place.isAdmin() && (execNodes.length > 0 || cloudProfiles.length > 0)),
popoverOpen: this.browser.placePopoverOpen,
popoverHiding: this.browser.placePopoverHiding,
submitting,
pendingCloud,
...commonPopover("project"),
browserTarget: this.browser.browserTarget,
browserListing: this.browser.browserListing,
browserLoading: this.browser.browserLoading,
@@ -478,12 +468,6 @@ class NewSessionPage extends OpenClawLightDomElement {
usableBrowserPath: this.browser.usableBrowserPath(),
registerProjectPath: this.browser.browserProjectPath,
registeringProject: this.browser.browserRegistering,
onGuardTransition: (event) => this.browser.guardPopoverTransition(event),
onPopoverShow: () => this.browser.onPopoverShow(),
onPopoverHide: () => this.browser.onPopoverHide(),
onPopoverAfterHide: () => this.browser.onPopoverAfterHide(),
onSelectExecNode: (nodeId) => this.place.selectExecNode(nodeId),
onSelectCloudProfile: (profileId) => this.place.selectCloudProfile(profileId),
onSelectProject: (projectId) => this.place.selectProjectId(projectId),
onProjectQueryInput: (query) => this.browser.changeProjectQuery(query),
onCloneProject: (gitUrl) => void this.browser.addRemoteProject(gitUrl),
@@ -500,12 +484,32 @@ class NewSessionPage extends OpenClawLightDomElement {
onBrowserNavigate: (path) => this.browser.loadBrowser(path),
onBrowserBack: () => this.browser.showRoot(),
onRegisterProject: (path) => void this.browser.registerBrowserProject(path),
onConnectMachine: () => this.openConnectMachine(),
onClose: () => this.browser.close(),
})}${renderDetailChip({
state: detailState,
syncLabel: projectState.label,
folder: this.place.folder,
execNode: this.place.execNode,
worktree: this.place.worktree,
worktreeAvailable: this.place.worktreeAvailable(),
worktreeDisabledReason:
this.place.repository.kind === "checking"
? t("newSession.checkingGit")
: this.place.repository.kind === "unavailable"
? t("newSession.gitCheckUnavailable")
: undefined,
branches,
branchesLoading: this.place.repository.kind === "checking",
baseRef: this.place.baseRef,
worktreeName: this.place.worktreeName,
submitting,
pendingCloud,
...commonPopover("detail"),
onToggleWorktree: () => this.place.toggleWorktree(),
onBaseRefInput: (baseRef) => this.place.setBaseRef(baseRef),
onWorktreeNameInput: (worktreeName) => this.place.setWorktreeName(worktreeName),
});
onNodeFolderInput: (folder, execNode) => this.place.applyFolder(folder, execNode),
})}`;
}
private openConnectMachine() {
@@ -0,0 +1,49 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { PresenceEntry } from "../../api/types.ts";
import type { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
export function readPresenceEntries(value: unknown): PresenceEntry[] | null {
const presence =
value && typeof value === "object" ? (value as { presence?: unknown }).presence : null;
return Array.isArray(presence) ? (presence as PresenceEntry[]) : null;
}
export function presenceStateSignature(entries: PresenceEntry[]): string {
const states = new Map<string, "connected" | "offline">();
for (const entry of entries) {
const id = (entry.deviceId ?? entry.instanceId)?.trim().toLowerCase();
if (!id || entry.mode?.trim().toLowerCase() === "gateway") {
continue;
}
states.set(id, entry.reason?.trim().toLowerCase() === "disconnect" ? "offline" : "connected");
}
return JSON.stringify([...states].toSorted(([left], [right]) => left.localeCompare(right)));
}
export function createControllerHost(element: OpenClawLightDomElement): ReactiveControllerHost {
return {
addController: (controller: ReactiveController) => element.addController(controller),
removeController: (controller: ReactiveController) => element.removeController(controller),
requestUpdate: () => element.requestUpdate(),
get updateComplete() {
return element.updateComplete;
},
};
}
export function closeAgentPicker(root: ParentNode) {
const dropdown = root.querySelector<HTMLElement & { open: boolean }>(
".new-session-page__select--agent wa-dropdown",
);
if (dropdown) {
dropdown.open = false;
}
}
export function closeSessionMenus(root: ParentNode) {
for (const selector of ["wa-dropdown[open]", "wa-popover.new-session-page__picker-popover"]) {
for (const menu of root.querySelectorAll<HTMLElement & { open: boolean }>(selector)) {
menu.open = false;
}
}
}
@@ -1,414 +0,0 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { readDraftEnvironments } from "./discovery.ts";
import { resolvePlacePickerSections } from "./place-picker-sections.ts";
import { projectCloneInput, renderPlaceSelect } from "./place-picker.ts";
type PlaceSelectParams = Parameters<typeof renderPlaceSelect>[0];
function placeParams(overrides: Partial<PlaceSelectParams> = {}): PlaceSelectParams {
return {
browseAvailable: true,
isAdmin: true,
canWrite: true,
folder: "/workspace",
workspace: "/workspace",
projects: [],
recents: [],
projectQuery: "",
projectSearchAvailable: true,
projectAddAvailable: true,
remoteProjects: [],
projectSearchCredential: null,
projectSearchLoading: false,
projectSearchError: null,
projectCloneBusy: false,
projectCloneError: null,
projectId: "",
execNodes: [],
environments: null,
gatewayName: "",
cloudProfiles: [],
cloudProfileId: "",
execNode: "",
syncFolder: "/workspace",
worktree: false,
worktreeVisible: false,
worktreeAvailable: false,
branches: null,
branchesLoading: false,
baseRef: "",
worktreeName: "",
submitting: false,
pendingCloud: false,
showDestinations: false,
popoverOpen: true,
popoverHiding: false,
browserTarget: null,
browserListing: null,
browserLoading: false,
browserError: null,
browserPathDraft: "",
usableBrowserPath: null,
registerProjectPath: null,
registeringProject: false,
onGuardTransition: () => undefined,
onPopoverShow: () => undefined,
onPopoverHide: () => undefined,
onPopoverAfterHide: () => undefined,
onSelectExecNode: () => undefined,
onSelectCloudProfile: () => undefined,
onSelectProject: () => undefined,
onProjectQueryInput: () => undefined,
onCloneProject: () => undefined,
onApplyFolder: () => undefined,
onBrowse: () => undefined,
onBrowserPathDraftChange: () => undefined,
onBrowserNavigate: () => undefined,
onBrowserBack: () => undefined,
onRegisterProject: () => undefined,
onConnectMachine: () => undefined,
onClose: () => undefined,
onToggleWorktree: () => undefined,
onBaseRefInput: () => undefined,
onWorktreeNameInput: () => undefined,
...overrides,
};
}
describe("project picker", () => {
it.each([
["https://github.com/openclaw/openclaw.git", true],
["git@github.com:openclaw/openclaw.git", true],
["ssh://git@github.com/openclaw/openclaw.git", true],
["file:///tmp/openclaw.git", false],
["/tmp/openclaw", false],
["--upload-pack=touch-pwned", false],
["https://github.com/openclaw/openclaw.git --config=evil", false],
])("detects clone input %s", (value, expected) => {
expect(projectCloneInput(value) !== null).toBe(expected);
});
it("shows bounded environment facts without default-state or infrastructure clutter", () => {
const container = document.createElement("div");
render(
renderPlaceSelect(
placeParams({
showDestinations: true,
worktreeAvailable: true,
execNodes: [
{
nodeId: "macbook",
displayName: "MacBook",
connected: true,
canExec: true,
canBrowse: true,
},
{
nodeId: "iphone",
displayName: "iPhone",
connected: true,
canExec: true,
canBrowse: false,
},
],
environments: readDraftEnvironments([
{
id: "gateway",
type: "local",
status: "available",
platform: "linux",
sessionHost: true,
trust: "persistent",
capabilities: ["sessions", "tools", "workspace"],
},
{
id: "node:macbook",
type: "node",
status: "unavailable",
platform: "darwin",
sessionHost: false,
trust: "persistent",
capabilities: [
"camera.snap",
"screen.record",
"voice",
"microphone.capture",
"system.run",
"fs.listDir",
"custom.unknown",
],
},
{
id: "node:iphone",
type: "node",
platform: "iOS 26.4",
capabilities: ["location.get", "talk.ptt.start", "canvas.navigate"],
},
]),
cloudProfiles: [
{ id: "aws", providerId: "crabbox", trust: "disposable" },
{ id: "shared", providerId: "static-ssh", trust: "persistent" },
{ id: "plain", providerId: "opaque-provider" },
],
}),
),
container,
);
const destinationHeadings = [
...container.querySelectorAll<HTMLElement>(".new-session-page__menu-title"),
]
.map((element) => element.textContent?.trim())
.filter((label) => ["This gateway", "Your devices", "Cloud", "Places"].includes(label ?? ""));
expect(destinationHeadings).toEqual(["This gateway", "Your devices", "Cloud"]);
expect(container.querySelector('[data-value="gateway"]')).not.toBeNull();
expect(container.querySelector('[data-value="node:macbook"]')).not.toBeNull();
expect(container.querySelector('[data-value="cloud:aws"]')).not.toBeNull();
expect(
[
...container.querySelectorAll('[data-value="node:macbook"] .new-session-page__menu-fact'),
].map((element) => element.textContent?.trim()),
).toEqual(["macOS", "Camera", "Screen capture", "Voice"]);
expect(
[
...container.querySelectorAll('[data-value="node:iphone"] .new-session-page__menu-fact'),
].map((element) => element.textContent?.trim()),
).toEqual(["iOS 26.4", "Location", "Talk", "Canvas"]);
expect(
[...container.querySelectorAll('[data-value="cloud:aws"] .new-session-page__menu-fact')].map(
(element) => element.textContent?.trim(),
),
).toEqual(["Disposable"]);
expect(
[
...container.querySelectorAll('[data-value="cloud:shared"] .new-session-page__menu-fact'),
].map((element) => element.textContent?.trim()),
).toEqual(["Persistent"]);
expect(
container.querySelector('[data-value="cloud:plain"] .new-session-page__menu-fact'),
).toBeNull();
expect(
container.querySelector('[data-value="gateway"] .new-session-page__menu-fact'),
).toBeNull();
const visibleCopy = container.textContent?.toLowerCase() ?? "";
for (const clutter of [
"available",
"online",
"session host",
"crabbox",
"static-ssh",
"opaque-provider",
"system.run",
"fs.listdir",
"custom.unknown",
]) {
expect(visibleCopy).not.toContain(clutter);
}
});
it("renders local matches before remote clone results and explains missing credentials", () => {
const onCloneProject = vi.fn();
const container = document.createElement("div");
render(
renderPlaceSelect(
placeParams({
projectQuery: "openclaw",
projects: [
{
id: "local-openclaw",
displayName: "Local OpenClaw",
repoRoot: "/workspace/openclaw",
source: "registered",
},
],
projectSearchCredential: "missing",
remoteProjects: [
{
name: "openclaw",
fullName: "openclaw/openclaw",
description: "Personal AI assistant",
cloneUrl: "https://github.com/openclaw/openclaw.git",
webUrl: "https://github.com/openclaw/openclaw",
private: false,
},
],
onCloneProject,
}),
),
container,
);
const values = [...container.querySelectorAll<HTMLElement>("[data-value]")].map(
(element) => element.dataset.value,
);
expect(values.indexOf("project:local-openclaw")).toBeLessThan(
values.indexOf("remote-project:openclaw/openclaw"),
);
expect(container.textContent).toContain("GH_TOKEN");
container
.querySelector<HTMLButtonElement>('[data-value="remote-project:openclaw/openclaw"]')
?.click();
expect(onCloneProject).toHaveBeenCalledWith("https://github.com/openclaw/openclaw.git");
});
it("turns a pasted URL into one explicit clone affordance", () => {
const onCloneProject = vi.fn();
const container = document.createElement("div");
const gitUrl = "https://github.com/openclaw/openclaw.git";
render(
renderPlaceSelect(
placeParams({
projectQuery: gitUrl,
remoteProjects: [
{
name: "ignored",
fullName: "ignored/remote",
cloneUrl: "https://github.com/ignored/remote.git",
webUrl: "https://github.com/ignored/remote",
private: false,
},
],
onCloneProject,
}),
),
container,
);
expect(container.querySelector('[data-value^="remote-project:"]')).toBeNull();
const clone = container.querySelector<HTMLButtonElement>('[data-value="project-clone-url"]');
expect(clone?.textContent).toContain("Clone");
clone?.click();
expect(onCloneProject).toHaveBeenCalledWith(gitUrl);
});
});
describe("Where picker", () => {
it("offers machine connection only to admins", () => {
const onConnectMachine = vi.fn();
const container = document.createElement("div");
render(renderPlaceSelect(placeParams({ isAdmin: true, onConnectMachine })), container);
const connect = container.querySelector<HTMLButtonElement>('[data-value="connect-machine"]');
expect(connect?.textContent?.trim()).toBe("Connect a machine…");
connect?.click();
expect(onConnectMachine).toHaveBeenCalledOnce();
render(renderPlaceSelect(placeParams({ isAdmin: false, onConnectMachine })), container);
expect(container.querySelector('[data-value="connect-machine"]')).toBeNull();
});
it("uses node presence until a non-empty authoritative environment catalog arrives", () => {
const execNodes = [
{
nodeId: "usable",
displayName: "Usable",
connected: true,
canExec: true,
canBrowse: false,
},
{
nodeId: "disconnected",
displayName: "Disconnected",
connected: false,
canExec: true,
canBrowse: false,
},
{
nodeId: "no-exec",
displayName: "No exec",
connected: true,
canExec: false,
canBrowse: false,
},
];
expect(
resolvePlacePickerSections({ environments: null, execNodes, cloudProfiles: [] }).deviceNodes,
).toEqual([execNodes[0]]);
expect(
resolvePlacePickerSections({ environments: [], execNodes, cloudProfiles: [] }).deviceNodes,
).toEqual([execNodes[0]]);
});
it("groups usable places from environment types and the legacy node catalog", () => {
const container = document.createElement("div");
const connectedExecNodes = [
"macbook",
"worker",
"local",
"missing-environment",
"future-type",
].map((nodeId) => ({
nodeId,
displayName: nodeId,
connected: true,
canExec: true,
canBrowse: false,
}));
render(
renderPlaceSelect(
placeParams({
folder: "",
execNodes: [
...connectedExecNodes,
{
nodeId: "offline",
displayName: "Offline Mac",
connected: false,
canExec: false,
canBrowse: false,
},
{
nodeId: "no-exec",
displayName: "No exec",
connected: true,
canExec: false,
canBrowse: false,
},
],
environments: readDraftEnvironments([
{ id: "gateway", type: "local" },
{ id: "node:macbook", type: "node" },
{ id: "node:worker", type: "worker" },
{ id: "node:local", type: "local" },
{ id: "node:offline", type: "node" },
{ id: "node:no-exec", type: "node" },
{ id: "node:future-type", type: "future" },
]),
gatewayName: "Studio",
cloudProfiles: [
{ id: "aws", providerId: "crabbox" },
{ id: "legacy", providerId: "static-ssh" },
],
worktreeAvailable: true,
showDestinations: true,
}),
),
container,
);
const titles = [...container.querySelectorAll(".new-session-page__menu-title")].map((element) =>
element.textContent?.trim(),
);
expect(titles).toEqual(["Folder", "Projects", "This gateway", "Your devices", "Cloud"]);
expect(container.querySelector('[data-value="node:macbook"]')).not.toBeNull();
for (const nodeId of [
"worker",
"local",
"missing-environment",
"future-type",
"offline",
"no-exec",
]) {
expect(container.querySelector(`[data-value="node:${nodeId}"]`)).toBeNull();
}
expect(container.querySelector('[data-value="cloud:aws"]')).not.toBeNull();
expect(container.querySelector('[data-value="cloud:legacy"]')).not.toBeNull();
const gateway = container.querySelector('[data-value="gateway"]');
expect(gateway?.lastElementChild?.classList.contains("session-menu__check")).toBe(true);
});
});
-579
View File
@@ -1,579 +0,0 @@
import { html, nothing } from "lit";
import type {
FsListDirResult,
ProjectRecord,
ProjectRecent,
RemoteProject,
} from "../../../../packages/gateway-protocol/src/index.js";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import {
renderCloudProfileMenuItems,
renderConnectMachineMenuItem,
renderSessionMenuItem,
} from "./cloud-target.ts";
import type {
BrowserTarget,
DraftBranches,
DraftCloudProfile,
DraftEnvironment,
DraftNode,
} from "./discovery.ts";
import { folderDisplayName } from "./path.ts";
import { renderPlaceBrowser } from "./place-browser.ts";
import { disambiguate, isPhoneFamily, nodeTooltip } from "./place-labels.ts";
import { resolvePlacePickerSections } from "./place-picker-sections.ts";
function parentFolderDisplayName(path: string): string | undefined {
const trimmed = path.replace(/[\\/]+$/u, "");
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
if (separator < 0) {
return undefined;
}
const parent = separator === 0 ? trimmed.slice(0, 1) : trimmed.slice(0, separator);
return folderDisplayName(parent) || undefined;
}
/** Detects pasted clone URLs; the Gateway remains authoritative for host validation. */
export function projectCloneInput(value: string): string | null {
const trimmed = value.trim();
if (!trimmed || trimmed.startsWith("-") || /\s/u.test(trimmed)) {
return null;
}
return /^(?:https:\/\/|ssh:\/\/git@|git@[^:]+:)/iu.test(trimmed) ? trimmed : null;
}
export function renderPlaceSelect(params: {
browseAvailable: boolean;
isAdmin: boolean;
canWrite: boolean;
folder: string;
workspace: string;
projects: readonly ProjectRecord[];
recents: readonly ProjectRecent[];
projectQuery: string;
projectSearchAvailable: boolean;
projectAddAvailable: boolean;
remoteProjects: readonly RemoteProject[];
projectSearchCredential: "configured" | "missing" | null;
projectSearchLoading: boolean;
projectSearchError: string | null;
projectCloneBusy: boolean;
projectCloneError: string | null;
projectId: string;
execNodes: DraftNode[];
environments: readonly DraftEnvironment[] | null;
gatewayName: string;
cloudProfiles: readonly DraftCloudProfile[];
cloudProfileId: string;
execNode: string;
syncFolder: string;
worktree: boolean;
worktreeVisible: boolean;
worktreeAvailable: boolean;
worktreeDisabledReason?: string;
cloudDisabledReason?: string;
branches: DraftBranches | null;
branchesLoading: boolean;
baseRef: string;
worktreeName: string;
submitting: boolean;
pendingCloud: boolean;
showDestinations: boolean;
popoverOpen: boolean;
popoverHiding: boolean;
browserTarget: BrowserTarget | null;
browserListing: FsListDirResult | null;
browserLoading: boolean;
browserError: string | null;
browserPathDraft: string;
usableBrowserPath: string | null;
registerProjectPath: string | null;
registeringProject: boolean;
onGuardTransition: (event: MouseEvent) => void;
onPopoverShow: () => void;
onPopoverHide: () => void;
onPopoverAfterHide: () => void;
onSelectExecNode: (nodeId: string) => void;
onSelectCloudProfile: (profileId: string) => void;
onSelectProject: (projectId: string) => void;
onProjectQueryInput: (query: string) => void;
onCloneProject: (gitUrl: string) => void;
onApplyFolder: (folder: string, execNode: string) => void;
onBrowse: (target: BrowserTarget) => void;
onBrowserPathDraftChange: (value: string) => void;
onBrowserNavigate: (path: string | undefined) => void;
onBrowserBack: () => void;
onRegisterProject: (path: string) => void;
onConnectMachine: () => void;
onClose: () => void;
onToggleWorktree: () => void;
onBaseRefInput: (baseRef: string) => void;
onWorktreeNameInput: (name: string) => void;
}) {
const folder = params.folder.trim();
const projectQuery = params.projectQuery.trim();
const cloneInput = projectCloneInput(params.projectQuery);
const normalizedProjectQuery = projectQuery.toLowerCase();
const localProjects = normalizedProjectQuery
? params.projects.filter((project) =>
[project.displayName, project.originUrl ?? "", project.repoRoot ?? ""]
.join("\n")
.toLowerCase()
.includes(normalizedProjectQuery),
)
: params.projects;
const selectedProject = params.projects.find((project) => project.id === params.projectId);
const folderLabel = selectedProject
? selectedProject.displayName
: folder
? folderDisplayName(folder)
: params.execNode
? t("newSession.folderPlaceholder")
: folderDisplayName(params.workspace) || t("newSession.folderPlaceholder");
const activeNode = params.execNodes.find((node) => node.nodeId === params.execNode);
const activeProfile = params.cloudProfiles.find(
(profile) => profile.id === params.cloudProfileId,
);
const { deviceNodes, deviceFacts, cloudProfiles } = resolvePlacePickerSections(params);
const gatewayLabel = params.gatewayName
? t("newSession.gatewayNamed", { name: params.gatewayName })
: t("newSession.gateway");
const destinationLabel = params.cloudProfileId
? t("newSession.cloudWorker", { profile: params.cloudProfileId })
: params.execNode
? (activeNode?.displayName ?? params.execNode)
: gatewayLabel;
const label = params.showDestinations ? `${folderLabel} · ${destinationLabel}` : folderLabel;
const effectiveFolder = folder || params.workspace;
const recents = params.recents.filter(
(recent) =>
recent.kind !== "folder" ||
!recent.execNode ||
deviceNodes.some((node) => node.nodeId === recent.execNode),
);
const recentItems = recents.map((recent) => {
const node =
recent.kind === "folder" && recent.execNode
? deviceNodes.find((candidate) => candidate.nodeId === recent.execNode)
: undefined;
const recentLabel =
params.showDestinations && node
? `${recent.displayName} · ${node.displayName}`
: recent.displayName;
return { ...recent, label: recentLabel, node };
});
const recentSuffixes = disambiguate(recentItems, (recent) => recent.label, [
(recent) => (recent.kind === "folder" ? parentFolderDisplayName(recent.folder) : undefined),
(recent) => (recent.kind === "folder" ? recent.folder : undefined),
(recent) => recent.node?.modelIdentifier,
(recent) => recent.node?.remoteIp,
(recent) =>
recent.kind === "folder"
? `${recent.folder}${recent.execNode ? ` · ${recent.execNode.slice(0, 8)}` : ""}`
: recent.projectId,
]);
const nodeSuffixes = disambiguate(deviceNodes, (node) => node.displayName, [
(node) => node.modelIdentifier,
(node) => node.remoteIp,
(node) => node.nodeId.slice(0, 8),
]);
const browseTarget: BrowserTarget = params.execNode
? { nodeId: params.execNode, label: activeNode?.displayName ?? params.execNode }
: { nodeId: "", label: gatewayLabel };
const nodeIcon = isPhoneFamily(activeNode?.deviceFamily)
? icons.monitorSmartphone
: icons.monitor;
const browseNeedsAdmin = !params.browseAvailable && !params.isAdmin;
// Native disabled buttons suppress pointer/focus events in some browsers, so the
// repair tooltip keeps only this limited-access state focusable and guards activation.
const browseButton = html`<button
type="button"
class="session-menu__item"
data-value="browse"
aria-pressed="false"
aria-disabled=${browseNeedsAdmin ? "true" : nothing}
?disabled=${params.submitting ||
params.pendingCloud ||
(!params.browseAvailable && !browseNeedsAdmin)}
@click=${() => {
if (params.browseAvailable && !params.submitting && !params.pendingCloud) {
params.onBrowse(browseTarget);
}
}}
>
<span class="session-menu__check" aria-hidden="true"></span>
<span class="session-menu__text">${t("newSession.browse")}</span>
<span class="new-session-page__menu-chevron" aria-hidden="true">${icons.chevronRight}</span>
</button>`;
return html`
<span class="new-session-page__select">
<button
id="new-session-place-trigger"
type="button"
class="new-session-page__trigger ${params.popoverHiding
? "new-session-page__trigger--hiding"
: ""}"
title=${t("newSession.where")}
aria-label="${t("newSession.where")}: ${label}"
data-worktree=${String(params.worktree)}
data-project-id=${params.projectId || nothing}
data-cloud-profile=${params.cloudProfileId || nothing}
aria-haspopup="dialog"
aria-expanded=${String(params.popoverOpen)}
?disabled=${params.submitting || params.pendingCloud}
@click=${params.onGuardTransition}
>
<span class="new-session-page__target-icon" aria-hidden="true"
>${params.cloudProfileId
? icons.server
: params.execNode
? nodeIcon
: selectedProject
? icons.gitBranch
: icons.folder}</span
>
<span class="new-session-page__trigger-label">${label}</span>
${params.worktree
? html`<span class="new-session-page__target-icon" aria-hidden="true"
>${icons.gitBranch}</span
>`
: nothing}
<span class="new-session-page__trigger-chevron" aria-hidden="true"
>${icons.chevronDown}</span
>
</button>
</span>
<wa-popover
class="new-session-page__select new-session-page__place-popover"
for="new-session-place-trigger"
placement="bottom-start"
without-arrow
@wa-show=${params.onPopoverShow}
@wa-hide=${params.onPopoverHide}
@wa-after-hide=${params.onPopoverAfterHide}
>
${params.browserTarget
? renderPlaceBrowser({
listing: params.browserListing,
target: params.browserTarget,
loading: params.browserLoading,
error: params.browserError,
pathDraft: params.browserPathDraft,
usablePath: params.usableBrowserPath,
registerProjectPath: params.registerProjectPath,
registeringProject: params.registeringProject,
onPathDraftChange: params.onBrowserPathDraftChange,
onNavigate: params.onBrowserNavigate,
onBack: params.onBrowserBack,
onRegisterProject: params.onRegisterProject,
onClose: params.onClose,
onApplyFolder: params.onApplyFolder,
})
: html`
<div class="new-session-page__place-root">
<div class="new-session-page__menu-title">${t("newSession.folder")}</div>
${params.workspace
? renderSessionMenuItem(
{
value: "workspace",
label: folderDisplayName(params.workspace),
checked:
!params.projectId &&
!params.execNode &&
effectiveFolder === params.workspace,
onSelect: () => params.onApplyFolder(params.workspace, ""),
},
params.submitting,
)
: nothing}
<div class="new-session-page__menu-title">${t("newSession.projects")}</div>
<label class="new-session-page__project-search">
<span class="sr-only">${t("newSession.projectSearchPlaceholder")}</span>
<input
type="search"
placeholder=${t("newSession.projectSearchPlaceholder")}
.value=${params.projectQuery}
?disabled=${params.submitting || params.pendingCloud || params.projectCloneBusy}
@input=${(event: Event) =>
params.onProjectQueryInput((event.target as HTMLInputElement).value)}
@keydown=${(event: KeyboardEvent) => {
if (event.key === "Enter" && cloneInput && params.projectAddAvailable) {
event.preventDefault();
params.onCloneProject(cloneInput);
}
}}
/>
</label>
${localProjects.map((project) =>
renderSessionMenuItem(
{
value: `project:${project.id}`,
label: project.displayName,
icon: icons.gitBranch,
checked: params.projectId === project.id,
title: project.repoRoot,
onSelect: () => params.onSelectProject(project.id),
},
params.submitting || params.projectCloneBusy,
),
)}
${cloneInput && params.projectAddAvailable
? renderSessionMenuItem(
{
value: "project-clone-url",
label: cloneInput,
icon: icons.gitBranch,
sub: t("newSession.cloneProject"),
checked: false,
keepOpen: true,
onSelect: () => params.onCloneProject(cloneInput),
},
params.submitting || params.projectCloneBusy,
)
: nothing}
${!cloneInput && projectQuery.length >= 2 && params.projectSearchAvailable
? html`
<div class="new-session-page__menu-title">
${t("newSession.githubProjects")}
</div>
${params.projectSearchCredential === "missing"
? html`<div class="new-session-page__menu-note">
${t("newSession.githubTokenHint")}
</div>`
: nothing}
${params.projectSearchLoading
? html`<div class="new-session-page__project-status" role="status">
${t("common.loading")}
</div>`
: nothing}
${params.projectSearchError
? html`<div class="new-session-page__project-error" role="alert">
${params.projectSearchError}
</div>`
: nothing}
${params.remoteProjects.map((project) =>
renderSessionMenuItem(
{
value: `remote-project:${project.fullName}`,
label: project.fullName,
icon: icons.gitBranch,
sub: project.description ?? t("newSession.cloneProject"),
checked: false,
title: project.webUrl,
keepOpen: true,
onSelect: () => params.onCloneProject(project.cloneUrl),
},
params.submitting || params.projectCloneBusy || !params.projectAddAvailable,
),
)}
`
: nothing}
${params.projectCloneBusy
? html`<div class="new-session-page__project-status" role="status">
${t("newSession.cloningProject")}
</div>`
: nothing}
${params.projectCloneError
? html`<div class="new-session-page__project-error" role="alert">
${params.projectCloneError}
</div>`
: nothing}
${params.projects.length === 0 && params.canWrite && !params.isAdmin
? html`<div class="new-session-page__menu-note">
${t("newSession.projectsAdminHint")}
</div>`
: nothing}
${recents.length > 0
? html`
<div class="new-session-page__menu-title">${t("newSession.recentFolders")}</div>
${recentItems.map((recent, index) => {
return renderSessionMenuItem(
{
value:
recent.kind === "project"
? `recent-project:${recent.projectId}`
: `recent:${recent.execNode ?? ""}:${recent.folder}`,
label: recent.label,
icon: recent.kind === "project" ? icons.gitBranch : icons.folder,
sub: recentSuffixes[index],
checked:
recent.kind === "project"
? params.projectId === recent.projectId
: !params.projectId &&
params.execNode === (recent.execNode ?? "") &&
folder === recent.folder,
title: recent.kind === "project" ? undefined : recent.folder,
onSelect: () =>
recent.kind === "project"
? params.onSelectProject(recent.projectId)
: params.onApplyFolder(recent.folder, recent.execNode ?? ""),
},
params.submitting,
);
})}
`
: nothing}
${browseNeedsAdmin
? html`<openclaw-tooltip .content=${t("newSession.browseRequiresAdmin")}>
${browseButton}
</openclaw-tooltip>`
: browseButton}
${params.showDestinations
? html`
<div class="new-session-page__menu-title">${t("newSession.thisGateway")}</div>
${renderSessionMenuItem(
{
value: "gateway",
label: gatewayLabel,
icon: icons.monitor,
checked: !params.execNode && !params.cloudProfileId,
onSelect: () => params.onSelectExecNode(""),
},
params.submitting,
)}
${deviceNodes.length > 0
? html`
<div class="new-session-page__menu-title">
${t("newSession.yourDevices")}
</div>
${deviceNodes.map((node, index) =>
renderSessionMenuItem(
{
value: `node:${node.nodeId}`,
label: node.displayName,
icon: isPhoneFamily(node.deviceFamily)
? icons.monitorSmartphone
: icons.monitor,
sub: nodeSuffixes[index],
facts: deviceFacts.get(node.nodeId),
checked: params.execNode === node.nodeId,
title: nodeTooltip(node),
onSelect: () => params.onSelectExecNode(node.nodeId),
},
params.submitting,
),
)}
`
: nothing}
${cloudProfiles.length > 0 || (params.cloudProfileId && !activeProfile)
? html`
<div class="new-session-page__menu-title">${t("newSession.cloud")}</div>
${renderCloudProfileMenuItems({
profiles: cloudProfiles,
selectedId: params.cloudProfileId,
submitting: params.submitting,
icon: icons.server,
disabled:
!params.worktreeAvailable || Boolean(params.cloudDisabledReason),
disabledReason: params.cloudDisabledReason,
onSelect: params.onSelectCloudProfile,
})}
${params.cloudProfileId && !activeProfile
? renderSessionMenuItem(
{
value: `cloud:${params.cloudProfileId}`,
label: t("newSession.cloudWorker", {
profile: params.cloudProfileId,
}),
icon: icons.server,
checked: true,
disabled: true,
title: t("newSession.catalogUnavailable"),
onSelect: () => undefined,
},
params.submitting,
)
: nothing}
${params.cloudProfileId && params.syncFolder
? html`<div class="new-session-page__menu-note">
${t("newSession.cloudSyncsFolder", {
folder: folderDisplayName(params.syncFolder),
})}
</div>`
: nothing}
`
: nothing}
`
: nothing}
${!params.execNode && params.worktreeVisible
? html`
<div class="session-menu__separator" role="separator"></div>
${renderSessionMenuItem(
{
value: "worktree",
label: t("newSession.worktree"),
checked: params.worktree,
// Failed discovery blocks enabling Worktree, but an existing selection
// must stay actionable so the user can clear the submit-blocking state.
disabled:
Boolean(params.cloudProfileId) ||
(!params.worktreeAvailable && !params.worktree),
title: params.cloudProfileId
? t("newSession.cloudRequiresWorktree")
: params.worktreeAvailable
? t("chat.runControls.newSessionWorktree")
: (params.worktreeDisabledReason ??
t("newSession.worktreeUnavailable")),
onSelect: params.onToggleWorktree,
keepOpen: true,
},
params.submitting,
)}
${params.worktree
? html`
<label class="new-session-page__menu-field">
<span>${t("newSession.baseBranch")}</span>
<input
type="text"
list="new-session-branches"
?disabled=${params.submitting || params.pendingCloud}
placeholder=${params.branchesLoading
? t("common.loading")
: (params.branches?.defaultBranch ?? t("newSession.baseBranch"))}
.value=${params.baseRef}
@input=${(event: Event) =>
params.onBaseRefInput(
(event.target as HTMLInputElement).value.trim(),
)}
/>
<datalist id="new-session-branches">
${(params.branches?.branches ?? []).map(
(branch) => html`<option value=${branch.name}></option>`,
)}
</datalist>
</label>
<label class="new-session-page__menu-field">
<span>${t("newSession.worktreeName")}</span>
<input
type="text"
?disabled=${params.submitting || params.pendingCloud}
placeholder=${t("newSession.worktreeNamePlaceholder")}
.value=${params.worktreeName}
@input=${(event: Event) =>
params.onWorktreeNameInput(
(event.target as HTMLInputElement).value.trim(),
)}
/>
</label>
`
: nothing}
`
: nothing}
${params.showDestinations
? nothing
: html`<div class="new-session-page__menu-note">
${t("newSession.runsOn", { place: gatewayLabel })}
</div>`}
${params.isAdmin
? renderConnectMachineMenuItem({
disabled: params.submitting || params.pendingCloud,
onSelect: params.onConnectMachine,
})
: nothing}
</div>
`}
</wa-popover>
`;
}
+19 -1
View File
@@ -18,7 +18,11 @@ describe("new-session browser preferences", () => {
patchNewSessionPreference("ws://one.example", "Main", {
workspace: "/workspace",
folder: "/workspace/project",
where: { kind: "cloud", id: "build-fleet" },
projectId: "openclaw",
worktree: true,
baseRef: "main",
worktreeName: "picker-redesign",
model: "openai/gpt-5.6-sol",
thinkingLevel: "high",
});
@@ -26,7 +30,11 @@ describe("new-session browser preferences", () => {
expect(loadNewSessionPreference("ws://one.example", "main")).toEqual({
workspace: "/workspace",
folder: "/workspace/project",
where: { kind: "cloud", id: "build-fleet" },
projectId: "openclaw",
worktree: true,
baseRef: "main",
worktreeName: "picker-redesign",
model: "openai/gpt-5.6-sol",
thinkingLevel: "high",
});
@@ -47,7 +55,17 @@ describe("new-session browser preferences", () => {
expect(key).not.toBeNull();
localStorage.setItem(
key ?? "",
JSON.stringify({ agents: { main: { folder: 42, model: [], worktree: "yes" } } }),
JSON.stringify({
agents: {
main: {
folder: 42,
where: { kind: "node", id: [] },
projectId: {},
model: [],
worktree: "yes",
},
},
}),
);
expect(loadNewSessionPreference("ws://one.example", "main")).toBeNull();
});
+41 -1
View File
@@ -8,10 +8,19 @@ const STORAGE_KEY_PREFIX = "openclaw.new-session.preferences.v1:";
const IDENTITY_KEY_PREFIX = "new-session.v1:";
export const PREFS_MIGRATION_KEY = "new-session.migration.v1";
export type NewSessionWhere =
| { kind: "local" }
| { kind: "node"; id: string }
| { kind: "cloud"; id: string };
export type NewSessionPreference = {
workspace?: string;
folder?: string;
where?: NewSessionWhere;
projectId?: string;
worktree?: boolean;
baseRef?: string;
worktreeName?: string;
model?: string;
thinkingLevel?: string;
};
@@ -31,21 +40,52 @@ function normalizePreference(value: unknown): NewSessionPreference | null {
const record = value;
const workspace = normalizeOptionalString(record.workspace);
const folder = normalizeOptionalString(record.folder);
const projectId = normalizeOptionalString(record.projectId);
const baseRef = normalizeOptionalString(record.baseRef);
const worktreeName = normalizeOptionalString(record.worktreeName);
const model = normalizeOptionalString(record.model);
const thinkingLevel = normalizeOptionalString(record.thinkingLevel);
const worktree = typeof record.worktree === "boolean" ? record.worktree : undefined;
if (!workspace && !folder && worktree === undefined && !model && !thinkingLevel) {
const where = normalizeWhere(record.where);
if (
!workspace &&
!folder &&
!where &&
!projectId &&
worktree === undefined &&
!baseRef &&
!worktreeName &&
!model &&
!thinkingLevel
) {
return null;
}
return {
...(workspace ? { workspace } : {}),
...(folder ? { folder } : {}),
...(where ? { where } : {}),
...(projectId ? { projectId } : {}),
...(worktree !== undefined ? { worktree } : {}),
...(baseRef ? { baseRef } : {}),
...(worktreeName ? { worktreeName } : {}),
...(model ? { model } : {}),
...(thinkingLevel ? { thinkingLevel } : {}),
};
}
function normalizeWhere(value: unknown): NewSessionWhere | undefined {
if (!isRecord(value) || typeof value.kind !== "string") {
return undefined;
}
if (value.kind === "local") {
return { kind: "local" };
}
const id = normalizeOptionalString(value.id);
return id && (value.kind === "node" || value.kind === "cloud")
? { kind: value.kind, id }
: undefined;
}
function readStore(storage: Storage, gatewayUrl: string): PersistedPreferences {
try {
const parsed = JSON.parse(storage.getItem(storageKey(gatewayUrl)) ?? "null") as unknown;
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { projectCloneInput, resolveProjectChip } from "./project-chip.ts";
const projects = [
{
id: "openclaw",
displayName: "OpenClaw",
repoRoot: "/workspace/openclaw",
source: "registered" as const,
},
{
id: "website",
displayName: "Website",
repoRoot: "/workspace/site",
source: "workspace" as const,
},
];
describe("What chip state", () => {
it.each([
{
name: "filters registered and workspace projects locally",
execNode: "",
query: "site",
expectedMode: "projects",
expectedProjects: ["website"],
expectedRecents: 0,
showWorkspace: false,
},
{
name: "switches to node-path mode without changing the source list",
execNode: "macbook",
query: "",
expectedMode: "node-path",
expectedProjects: ["openclaw", "website"],
expectedRecents: 1,
showWorkspace: false,
},
])(
"$name",
({ execNode, query, expectedMode, expectedProjects, expectedRecents, showWorkspace }) => {
const state = resolveProjectChip({
folder: "",
workspace: "/workspace",
projectId: "",
projects,
recents: [
{
kind: "folder",
folder: "/remote/project",
displayName: "project",
execNode: "macbook",
},
],
projectQuery: query,
execNode,
});
expect(state.mode).toBe(expectedMode);
expect(state.localProjects.map((project) => project.id)).toEqual(expectedProjects);
expect(state.recents).toHaveLength(expectedRecents);
expect(state.showWorkspace).toBe(showWorkspace);
},
);
it.each([
["https://github.com/openclaw/openclaw.git", true],
["git@github.com:openclaw/openclaw.git", true],
["file:///tmp/openclaw.git", false],
["--upload-pack=touch-pwned", false],
["https://github.com/openclaw/openclaw.git --config=evil", false],
])("recognizes safe clone input %s", (value, expected) => {
expect(projectCloneInput(value) !== null).toBe(expected);
});
});
+393
View File
@@ -0,0 +1,393 @@
import { html, nothing } from "lit";
import type {
FsListDirResult,
ProjectRecord,
ProjectRecent,
RemoteProject,
} from "../../../../packages/gateway-protocol/src/index.js";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import { renderSessionMenuItem } from "./cloud-target.ts";
import type { BrowserTarget, DraftNode } from "./discovery.ts";
import { folderDisplayName } from "./path.ts";
import { renderPlaceBrowser } from "./place-browser.ts";
import { disambiguate } from "./place-labels.ts";
/** Detects pasted clone URLs; the Gateway remains authoritative for host validation. */
export function projectCloneInput(value: string): string | null {
const trimmed = value.trim();
if (!trimmed || trimmed.startsWith("-") || /\s/u.test(trimmed)) {
return null;
}
return /^(?:https:\/\/|ssh:\/\/git@|git@[^:]+:)/iu.test(trimmed) ? trimmed : null;
}
function parentFolderDisplayName(path: string): string | undefined {
const trimmed = path.replace(/[\\/]+$/u, "");
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
if (separator < 0) {
return undefined;
}
const parent = separator === 0 ? trimmed.slice(0, 1) : trimmed.slice(0, separator);
return folderDisplayName(parent) || undefined;
}
type ProjectChipState = Readonly<{
mode: "projects" | "node-path";
label: string;
localProjects: readonly ProjectRecord[];
recents: readonly ProjectRecent[];
showWorkspace: boolean;
}>;
export function resolveProjectChip(params: {
folder: string;
workspace: string;
projectId: string;
projects: readonly ProjectRecord[];
recents: readonly ProjectRecent[];
projectQuery: string;
execNode: string;
}): ProjectChipState {
const folder = params.folder.trim();
const selectedProject = params.projects.find((project) => project.id === params.projectId);
const normalizedQuery = params.projectQuery.trim().toLowerCase();
const mode = params.execNode ? "node-path" : "projects";
const localProjects = normalizedQuery
? params.projects.filter((project) =>
[project.displayName, project.originUrl ?? "", project.repoRoot ?? ""]
.join("\n")
.toLowerCase()
.includes(normalizedQuery),
)
: params.projects;
return {
mode,
label: selectedProject
? selectedProject.displayName
: folder
? folderDisplayName(folder)
: folderDisplayName(params.workspace) || t("newSession.folderPlaceholder"),
localProjects,
recents: params.execNode
? params.recents.filter(
(recent) => recent.kind === "folder" && recent.execNode === params.execNode,
)
: normalizedQuery
? []
: params.recents,
showWorkspace:
mode === "projects" &&
(!normalizedQuery ||
[folderDisplayName(params.workspace), params.workspace]
.join("\n")
.toLowerCase()
.includes(normalizedQuery)),
};
}
export function renderProjectChip(params: {
state: ProjectChipState;
browseAvailable: boolean;
isAdmin: boolean;
canWrite: boolean;
folder: string;
workspace: string;
projects: readonly ProjectRecord[];
projectQuery: string;
projectSearchAvailable: boolean;
projectAddAvailable: boolean;
remoteProjects: readonly RemoteProject[];
projectSearchCredentialMissing: boolean;
projectSearchLoading: boolean;
projectSearchError: string | null;
projectCloneBusy: boolean;
projectCloneError: string | null;
projectId: string;
execNodes: readonly DraftNode[];
gatewayLabel: string;
execNode: string;
submitting: boolean;
pendingCloud: boolean;
popoverOpen: boolean;
popoverHiding: boolean;
browserTarget: BrowserTarget | null;
browserListing: FsListDirResult | null;
browserLoading: boolean;
browserError: string | null;
browserPathDraft: string;
usableBrowserPath: string | null;
registerProjectPath: string | null;
registeringProject: boolean;
onGuardTransition: (event: MouseEvent) => void;
onPopoverShow: () => void;
onPopoverHide: () => void;
onPopoverAfterHide: () => void;
onSelectProject: (projectId: string) => void;
onProjectQueryInput: (query: string) => void;
onCloneProject: (gitUrl: string) => void;
onApplyFolder: (folder: string, execNode: string) => void;
onBrowse: (target: BrowserTarget) => void;
onBrowserPathDraftChange: (value: string) => void;
onBrowserNavigate: (path: string | undefined) => void;
onBrowserBack: () => void;
onRegisterProject: (path: string) => void;
onClose: () => void;
}) {
const folder = params.folder.trim();
const cloneInput = projectCloneInput(params.projectQuery);
const query = params.projectQuery.trim();
const activeNode = params.execNodes.find((node) => node.nodeId === params.execNode);
const browseTarget: BrowserTarget = params.execNode
? { nodeId: params.execNode, label: activeNode?.displayName ?? params.execNode }
: { nodeId: "", label: params.gatewayLabel };
const browseNeedsAdmin = !params.browseAvailable && !params.isAdmin;
const recentItems = params.state.recents.map((recent) => ({
...recent,
node:
recent.kind === "folder" && recent.execNode
? params.execNodes.find((node) => node.nodeId === recent.execNode)
: undefined,
}));
const recentSuffixes = disambiguate(recentItems, (recent) => recent.displayName, [
(recent) => (recent.kind === "folder" ? parentFolderDisplayName(recent.folder) : undefined),
(recent) => (recent.kind === "folder" ? recent.folder : undefined),
(recent) => recent.node?.modelIdentifier,
(recent) => recent.node?.remoteIp,
(recent) =>
recent.kind === "folder"
? `${recent.folder}${recent.execNode ? ` · ${recent.execNode.slice(0, 8)}` : ""}`
: recent.projectId,
]);
const browseButton = html`
<button
type="button"
class="session-menu__item"
data-value="browse"
aria-pressed="false"
aria-disabled=${browseNeedsAdmin ? "true" : nothing}
?disabled=${params.submitting ||
params.pendingCloud ||
(!params.browseAvailable && !browseNeedsAdmin)}
@click=${() => {
if (params.browseAvailable && !params.submitting && !params.pendingCloud) {
params.onBrowse(browseTarget);
}
}}
>
<span class="session-menu__check" aria-hidden="true"></span>
<span class="session-menu__text">${t("newSession.browse")}</span>
<span class="new-session-page__menu-chevron" aria-hidden="true">${icons.chevronRight}</span>
</button>
`;
return html`
<span class="new-session-page__select">
<button
id="new-session-project-trigger"
type="button"
class="new-session-page__trigger ${params.popoverHiding
? "new-session-page__trigger--hiding"
: ""}"
title=${t("newSession.what")}
aria-label="${t("newSession.what")}: ${params.state.label}"
data-project-id=${params.projectId || nothing}
aria-haspopup="dialog"
aria-expanded=${String(params.popoverOpen)}
?disabled=${params.submitting || params.pendingCloud}
@click=${params.onGuardTransition}
>
<span class="new-session-page__target-icon" aria-hidden="true"
>${params.projectId ? icons.gitBranch : icons.folder}</span
>
<span class="new-session-page__trigger-label">${params.state.label}</span>
<span class="new-session-page__trigger-chevron" aria-hidden="true"
>${icons.chevronDown}</span
>
</button>
</span>
<wa-popover
class="new-session-page__select new-session-page__project-popover new-session-page__picker-popover"
for="new-session-project-trigger"
placement="bottom-start"
without-arrow
@wa-show=${params.onPopoverShow}
@wa-hide=${params.onPopoverHide}
@wa-after-hide=${params.onPopoverAfterHide}
>
${params.browserTarget
? renderPlaceBrowser({
listing: params.browserListing,
target: params.browserTarget,
loading: params.browserLoading,
error: params.browserError,
pathDraft: params.browserPathDraft,
usablePath: params.usableBrowserPath,
registerProjectPath: params.registerProjectPath,
registeringProject: params.registeringProject,
onPathDraftChange: params.onBrowserPathDraftChange,
onNavigate: params.onBrowserNavigate,
onBack: params.onBrowserBack,
onRegisterProject: params.onRegisterProject,
onClose: params.onClose,
onApplyFolder: params.onApplyFolder,
})
: html`
<div class="new-session-page__picker-root">
${params.state.mode === "projects"
? html`
${params.workspace && params.state.showWorkspace
? renderSessionMenuItem(
{
value: "workspace",
label: folderDisplayName(params.workspace),
icon: icons.folder,
checked:
!params.projectId && !params.execNode && folder === params.workspace,
onSelect: () => params.onApplyFolder(params.workspace, ""),
},
params.submitting,
)
: nothing}
<label class="new-session-page__project-search">
<span class="sr-only">${t("newSession.projectSearchPlaceholder")}</span>
<input
type="search"
placeholder=${t("newSession.projectSearchPlaceholder")}
.value=${params.projectQuery}
?disabled=${params.submitting ||
params.pendingCloud ||
params.projectCloneBusy}
@input=${(event: Event) =>
params.onProjectQueryInput((event.target as HTMLInputElement).value)}
@keydown=${(event: KeyboardEvent) => {
if (event.key === "Enter" && cloneInput && params.projectAddAvailable) {
event.preventDefault();
params.onCloneProject(cloneInput);
}
}}
/>
</label>
${params.state.localProjects.map((project) =>
renderSessionMenuItem(
{
value: `project:${project.id}`,
label: project.displayName,
icon: icons.gitBranch,
checked: params.projectId === project.id,
title: project.repoRoot,
onSelect: () => params.onSelectProject(project.id),
},
params.submitting || params.projectCloneBusy,
),
)}
${cloneInput && params.projectAddAvailable
? renderSessionMenuItem(
{
value: "project-clone-url",
label: cloneInput,
icon: icons.gitBranch,
sub: t("newSession.cloneProject"),
checked: false,
keepOpen: true,
onSelect: () => params.onCloneProject(cloneInput),
},
params.submitting || params.projectCloneBusy,
)
: nothing}
${!cloneInput && query.length >= 2 && params.projectSearchAvailable
? html`
<div class="new-session-page__menu-title">
${t("newSession.githubProjects")}
</div>
${params.projectSearchCredentialMissing
? html`<div class="new-session-page__menu-note">
${t("newSession.githubTokenHint")}
</div>`
: nothing}
${params.projectSearchLoading
? html`<div class="new-session-page__project-status" role="status">
${t("common.loading")}
</div>`
: nothing}
${params.projectSearchError
? html`<div class="new-session-page__project-error" role="alert">
${params.projectSearchError}
</div>`
: nothing}
${params.remoteProjects.map((project) =>
renderSessionMenuItem(
{
value: `remote-project:${project.fullName}`,
label: project.fullName,
icon: icons.gitBranch,
sub: project.description ?? t("newSession.cloneProject"),
checked: false,
title: project.webUrl,
keepOpen: true,
onSelect: () => params.onCloneProject(project.cloneUrl),
},
params.submitting ||
params.projectCloneBusy ||
!params.projectAddAvailable,
),
)}
`
: nothing}
${params.projectCloneBusy
? html`<div class="new-session-page__project-status" role="status">
${t("newSession.cloningProject")}
</div>`
: nothing}
${params.projectCloneError
? html`<div class="new-session-page__project-error" role="alert">
${params.projectCloneError}
</div>`
: nothing}
${params.projects.length === 0 && params.canWrite && !params.isAdmin
? html`<div class="new-session-page__menu-note">
${t("newSession.projectsAdminHint")}
</div>`
: nothing}
`
: nothing}
${params.state.recents.length > 0
? html`
<div class="new-session-page__menu-title">${t("newSession.recentFolders")}</div>
${recentItems.map((recent, index) =>
renderSessionMenuItem(
{
value:
recent.kind === "project"
? `recent-project:${recent.projectId}`
: `recent:${recent.execNode ?? ""}:${recent.folder}`,
label: recent.displayName,
icon: recent.kind === "project" ? icons.gitBranch : icons.folder,
sub: recent.node?.displayName ?? recentSuffixes[index],
checked:
recent.kind === "project"
? params.projectId === recent.projectId
: !params.projectId &&
params.execNode === (recent.execNode ?? "") &&
folder === recent.folder,
title: recent.kind === "project" ? undefined : recent.folder,
onSelect: () =>
recent.kind === "project"
? params.onSelectProject(recent.projectId)
: params.onApplyFolder(recent.folder, recent.execNode ?? ""),
},
params.submitting,
),
)}
`
: nothing}
${browseNeedsAdmin
? html`<openclaw-tooltip .content=${t("newSession.browseRequiresAdmin")}>
${browseButton}
</openclaw-tooltip>`
: browseButton}
</div>
`}
</wa-popover>
`;
}
+167
View File
@@ -0,0 +1,167 @@
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { readDraftEnvironments } from "./discovery.ts";
import { renderWhereChip, resolveWhereChip } from "./where-chip.ts";
describe("Where chip state", () => {
const nodes = [
{
nodeId: "macbook",
displayName: "MacBook",
connected: true,
canExec: true,
canBrowse: true,
},
{
nodeId: "offline",
displayName: "Offline",
connected: false,
canExec: true,
canBrowse: true,
},
];
it.each([
{
name: "keeps Local visible without alternate destinations",
params: {
execNodes: [],
environments: [],
cloudProfiles: [],
execNode: "",
cloudProfileId: "",
},
expected: { kind: "local", label: "Local", nodes: [] },
},
{
name: "uses the selected eligible node name",
params: {
execNodes: nodes,
environments: readDraftEnvironments([{ id: "node:macbook", type: "node" }]),
cloudProfiles: [],
execNode: "macbook",
cloudProfileId: "",
},
expected: { kind: "node", label: "MacBook", nodes: ["macbook"] },
},
{
name: "treats a cloud profile as a place",
params: {
execNodes: nodes,
environments: [],
cloudProfiles: [{ id: "build-fleet", providerId: "crabbox" }],
execNode: "",
cloudProfileId: "build-fleet",
},
expected: { kind: "cloud", label: "build-fleet", nodes: ["macbook"] },
},
])("$name", ({ params, expected }) => {
const state = resolveWhereChip(params);
expect({
kind: state.kind,
label: state.label,
nodes: state.deviceNodes.map((node) => node.nodeId),
}).toEqual(expected);
});
it("shows bounded environment facts without default-state or infrastructure clutter", () => {
const state = resolveWhereChip({
execNodes: [
...nodes,
{
nodeId: "iphone",
displayName: "iPhone",
connected: true,
canExec: true,
canBrowse: false,
},
],
environments: readDraftEnvironments([
{
id: "gateway",
type: "local",
platform: "linux",
sessionHost: true,
trust: "persistent",
capabilities: ["sessions", "tools", "workspace"],
},
{
id: "node:macbook",
type: "node",
platform: "darwin",
trust: "persistent",
capabilities: [
"camera.snap",
"screen.record",
"voice",
"microphone.capture",
"system.run",
"fs.listDir",
"custom.unknown",
],
},
{
id: "node:iphone",
type: "node",
platform: "iOS 26.4",
capabilities: ["location.get", "talk.ptt.start", "canvas.navigate"],
},
]),
cloudProfiles: [
{ id: "aws", providerId: "crabbox", trust: "disposable" },
{ id: "shared", providerId: "static-ssh", trust: "persistent" },
{ id: "plain", providerId: "opaque-provider" },
],
execNode: "",
cloudProfileId: "",
});
const container = document.createElement("div");
render(
renderWhereChip({
state,
gatewayName: "",
cloudProfileId: "",
execNode: "",
worktreeAvailable: true,
submitting: false,
pendingCloud: false,
popoverOpen: true,
popoverHiding: false,
isAdmin: true,
onGuardTransition: () => undefined,
onPopoverShow: () => undefined,
onPopoverHide: () => undefined,
onPopoverAfterHide: () => undefined,
onSelectExecNode: () => undefined,
onSelectCloudProfile: () => undefined,
onConnectMachine: () => undefined,
}),
container,
);
const facts = (value: string) =>
[...container.querySelectorAll(`[data-value="${value}"] .new-session-page__menu-fact`)].map(
(element) => element.textContent?.trim(),
);
expect(facts("node:macbook")).toEqual(["macOS", "Camera", "Screen capture", "Voice"]);
expect(facts("node:iphone")).toEqual(["iOS 26.4", "Location", "Talk", "Canvas"]);
expect(facts("cloud:aws")).toEqual(["Disposable"]);
expect(facts("cloud:shared")).toEqual(["Persistent"]);
expect(facts("cloud:plain")).toEqual([]);
expect(facts("gateway")).toEqual([]);
const visibleCopy = container.textContent?.toLowerCase() ?? "";
for (const clutter of [
"available",
"online",
"session host",
"crabbox",
"static-ssh",
"opaque-provider",
"system.run",
"fs.listdir",
"custom.unknown",
]) {
expect(visibleCopy).not.toContain(clutter);
}
});
});
+192
View File
@@ -0,0 +1,192 @@
import { html, nothing } from "lit";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import {
renderCloudProfileMenuItems,
renderConnectMachineMenuItem,
renderSessionMenuItem,
} from "./cloud-target.ts";
import type { DraftCloudProfile, DraftEnvironment, DraftNode } from "./discovery.ts";
import { disambiguate, isPhoneFamily, nodeTooltip } from "./place-labels.ts";
import { resolvePlacePickerSections } from "./place-picker-sections.ts";
type WhereChipState = Readonly<{
kind: "local" | "node" | "cloud";
label: string;
deviceNodes: readonly DraftNode[];
deviceFacts: ReadonlyMap<string, readonly string[]>;
cloudProfiles: readonly DraftCloudProfile[];
}>;
export function resolveWhereChip(params: {
execNodes: readonly DraftNode[];
environments: readonly DraftEnvironment[] | null;
cloudProfiles: readonly DraftCloudProfile[];
cloudProfileId: string;
execNode: string;
}): WhereChipState {
const sections = resolvePlacePickerSections(params);
const node = sections.deviceNodes.find((candidate) => candidate.nodeId === params.execNode);
const profile = sections.cloudProfiles.find(
(candidate) => candidate.id === params.cloudProfileId,
);
if (params.cloudProfileId) {
return {
kind: "cloud",
label: profile?.id ?? params.cloudProfileId,
...sections,
};
}
if (params.execNode) {
return {
kind: "node",
label: node?.displayName ?? params.execNode,
...sections,
};
}
return { kind: "local", label: t("newSession.local"), ...sections };
}
export function renderWhereChip(params: {
state: WhereChipState;
gatewayName: string;
cloudProfileId: string;
execNode: string;
worktreeAvailable: boolean;
cloudDisabledReason?: string;
submitting: boolean;
pendingCloud: boolean;
popoverOpen: boolean;
popoverHiding: boolean;
isAdmin: boolean;
onGuardTransition: (event: MouseEvent) => void;
onPopoverShow: () => void;
onPopoverHide: () => void;
onPopoverAfterHide: () => void;
onSelectExecNode: (nodeId: string) => void;
onSelectCloudProfile: (profileId: string) => void;
onConnectMachine: () => void;
}) {
const activeNode = params.state.deviceNodes.find((node) => node.nodeId === params.execNode);
const icon =
params.state.kind === "cloud"
? icons.server
: params.state.kind === "node" && isPhoneFamily(activeNode?.deviceFamily)
? icons.monitorSmartphone
: icons.monitor;
const gatewayTitle = params.gatewayName
? t("newSession.gatewayNamed", { name: params.gatewayName })
: t("newSession.gateway");
const nodeSuffixes = disambiguate(params.state.deviceNodes, (node) => node.displayName, [
(node) => node.modelIdentifier,
(node) => node.remoteIp,
(node) => node.nodeId.slice(0, 8),
]);
return html`
<span class="new-session-page__select">
<button
id="new-session-where-trigger"
type="button"
class="new-session-page__trigger ${params.popoverHiding
? "new-session-page__trigger--hiding"
: ""}"
title=${t("newSession.where")}
aria-label="${t("newSession.where")}: ${params.state.label}"
data-cloud-profile=${params.cloudProfileId || nothing}
data-exec-node=${params.execNode || nothing}
aria-haspopup="dialog"
aria-expanded=${String(params.popoverOpen)}
?disabled=${params.submitting || params.pendingCloud}
@click=${params.onGuardTransition}
>
<span class="new-session-page__target-icon" aria-hidden="true">${icon}</span>
<span class="new-session-page__trigger-label">${params.state.label}</span>
<span class="new-session-page__trigger-chevron" aria-hidden="true"
>${icons.chevronDown}</span
>
</button>
</span>
<wa-popover
class="new-session-page__select new-session-page__where-popover new-session-page__picker-popover"
for="new-session-where-trigger"
placement="bottom-start"
without-arrow
@wa-show=${params.onPopoverShow}
@wa-hide=${params.onPopoverHide}
@wa-after-hide=${params.onPopoverAfterHide}
>
<div class="new-session-page__picker-root">
${renderSessionMenuItem(
{
value: "gateway",
label: t("newSession.local"),
icon: icons.monitor,
sub: params.gatewayName || undefined,
checked: !params.execNode && !params.cloudProfileId,
title: gatewayTitle,
onSelect: () => params.onSelectExecNode(""),
},
params.submitting,
)}
${params.state.deviceNodes.length > 0
? html`
<div class="new-session-page__menu-title">${t("newSession.yourDevices")}</div>
${params.state.deviceNodes.map((node, index) =>
renderSessionMenuItem(
{
value: `node:${node.nodeId}`,
label: node.displayName,
icon: isPhoneFamily(node.deviceFamily)
? icons.monitorSmartphone
: icons.monitor,
sub: nodeSuffixes[index],
facts: params.state.deviceFacts.get(node.nodeId),
checked: params.execNode === node.nodeId,
title: nodeTooltip(node),
onSelect: () => params.onSelectExecNode(node.nodeId),
},
params.submitting,
),
)}
`
: nothing}
${params.state.cloudProfiles.length > 0 || params.cloudProfileId
? html`
<div class="new-session-page__menu-title">${t("newSession.cloud")}</div>
${renderCloudProfileMenuItems({
profiles: params.state.cloudProfiles,
selectedId: params.cloudProfileId,
submitting: params.submitting,
icon: icons.server,
disabled: !params.worktreeAvailable || Boolean(params.cloudDisabledReason),
disabledReason: params.cloudDisabledReason,
onSelect: params.onSelectCloudProfile,
})}
${params.cloudProfileId &&
!params.state.cloudProfiles.some((profile) => profile.id === params.cloudProfileId)
? renderSessionMenuItem(
{
value: `cloud:${params.cloudProfileId}`,
label: t("newSession.cloudWorker", { profile: params.cloudProfileId }),
icon: icons.server,
checked: true,
disabled: true,
title: t("newSession.catalogUnavailable"),
onSelect: () => undefined,
},
params.submitting,
)
: nothing}
`
: nothing}
${params.isAdmin
? renderConnectMachineMenuItem({
disabled: params.submitting || params.pendingCloud,
onSelect: params.onConnectMachine,
})
: nothing}
</div>
</wa-popover>
`;
}
+6 -6
View File
@@ -355,21 +355,21 @@ wa-popover.new-session-page__select::part(body) {
white-space: nowrap;
}
/* Place browsing uses the same shell as its root menu, with inset root rows. */
wa-popover.new-session-page__place-popover {
/* Project browsing uses a wider shell for paths and remote result details. */
wa-popover.new-session-page__project-popover {
--max-width: min(420px, calc(100vw - 24px));
}
wa-popover.new-session-page__place-popover::part(body) {
wa-popover.new-session-page__project-popover::part(body) {
width: min(420px, calc(100vw - 24px));
max-width: none;
padding: 0;
overflow: hidden;
}
/* part(body) hides overflow for the browser view's square corners, so the
root menu needs its own scroll region or long place lists become unreachable. */
.new-session-page__place-root {
/* Popovers need their own scroll region because project and destination
catalogs can grow beyond the available viewport. */
.new-session-page__picker-root {
max-height: min(420px, 60vh);
padding: var(--menu-padding);
overflow-y: auto;