diff --git a/ui/src/e2e/device-scope-upgrade.e2e.test.ts b/ui/src/e2e/device-scope-upgrade.e2e.test.ts index d74859c1ac93..ff471ccb9dfa 100644 --- a/ui/src/e2e/device-scope-upgrade.e2e.test.ts +++ b/ui/src/e2e/device-scope-upgrade.e2e.test.ts @@ -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(); diff --git a/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts b/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts index 2b34d32580a7..96e3c465f790 100644 --- a/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts +++ b/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts @@ -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(); diff --git a/ui/src/e2e/new-session-page.cloud-startup-failure.e2e.test.ts b/ui/src/e2e/new-session-page.cloud-startup-failure.e2e.test.ts index 542bc93a3f00..bb6d15fb4e34 100644 --- a/ui/src/e2e/new-session-page.cloud-startup-failure.e2e.test.ts +++ b/ui/src/e2e/new-session-page.cloud-startup-failure.e2e.test.ts @@ -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"); diff --git a/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts b/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts index b67b5e168f69..bdff0ae58f54 100644 --- a/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts +++ b/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts @@ -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(() => { diff --git a/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts b/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts index b39f0afe03a2..cdf74c217a1f 100644 --- a/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts +++ b/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts @@ -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"]'); diff --git a/ui/src/e2e/new-session-page.environment-metadata.e2e.test.ts b/ui/src/e2e/new-session-page.environment-metadata.e2e.test.ts index 28f838ba2abb..3f0d324125ff 100644 --- a/ui/src/e2e/new-session-page.environment-metadata.e2e.test.ts +++ b/ui/src/e2e/new-session-page.environment-metadata.e2e.test.ts @@ -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); diff --git a/ui/src/e2e/new-session-page.github-projects.e2e.test.ts b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts index 4f1820f94a81..b8d5234f6229 100644 --- a/ui/src/e2e/new-session-page.github-projects.e2e.test.ts +++ b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts @@ -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", diff --git a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts index f78406a9c9e6..c81eac73b906 100644 --- a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts +++ b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts @@ -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); diff --git a/ui/src/e2e/new-session-page.place-preferences.e2e.test.ts b/ui/src/e2e/new-session-page.place-preferences.e2e.test.ts new file mode 100644 index 000000000000..12b32da44f0a --- /dev/null +++ b/ui/src/e2e/new-session-page.place-preferences.e2e.test.ts @@ -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(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.places-live.e2e.test.ts b/ui/src/e2e/new-session-page.places-live.e2e.test.ts index c3dba4e539f9..1a8fc50fc68d 100644 --- a/ui/src/e2e/new-session-page.places-live.e2e.test.ts +++ b/ui/src/e2e/new-session-page.places-live.e2e.test.ts @@ -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(); diff --git a/ui/src/e2e/new-session-page.places.e2e.test.ts b/ui/src/e2e/new-session-page.places.e2e.test.ts index b162a31e6ff0..49dbc0be6c5f 100644 --- a/ui/src/e2e/new-session-page.places.e2e.test.ts +++ b/ui/src/e2e/new-session-page.places.e2e.test.ts @@ -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((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(); diff --git a/ui/src/e2e/new-session-page.projects-places.e2e.test.ts b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts index aa189ff9b0e2..0c132d521983 100644 --- a/ui/src/e2e/new-session-page.projects-places.e2e.test.ts +++ b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts @@ -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(); diff --git a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts index fd6e7dfe5ef5..23f030b3a568 100644 --- a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts +++ b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts @@ -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(); diff --git a/ui/src/e2e/new-session-page.test-support.ts b/ui/src/e2e/new-session-page.test-support.ts index 582e4c9bfd19..22950c8175a9 100644 --- a/ui/src/e2e/new-session-page.test-support.ts +++ b/ui/src/e2e/new-session-page.test-support.ts @@ -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(); diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts index 20a59f391b28..087f4afe15d8 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -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"); diff --git a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts index c70471f80942..de493a7241f4 100644 --- a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts @@ -69,8 +69,8 @@ async function withNewSessionPage( type MockGateway = Awaited>; 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"); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 8b5f2158044c..41b4964f5ac0 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/new-session/detail-chip.test.ts b/ui/src/pages/new-session/detail-chip.test.ts new file mode 100644 index 000000000000..6c2e12fff157 --- /dev/null +++ b/ui/src/pages/new-session/detail-chip.test.ts @@ -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('[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"); + }); +}); diff --git a/ui/src/pages/new-session/detail-chip.ts b/ui/src/pages/new-session/detail-chip.ts new file mode 100644 index 000000000000..07adb57a0d78 --- /dev/null +++ b/ui/src/pages/new-session/detail-chip.ts @@ -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` + + + + +
+ ${params.state.mode === "node" + ? html` + + ` + : 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`
+ ${t("newSession.cloudRequiresWorktree")} +
` + : nothing} + ${params.state.mode === "cloud" + ? html`
+ ${t("newSession.cloudSyncsFolder", { folder: params.syncLabel })} +
` + : nothing} + ${params.worktree + ? html` + + + ` + : html`
+ ${t("newSession.runsDirectlyNote")} +
`} + ` + : html`
+ ${params.state.mode === "checking" + ? t("newSession.checkingGit") + : params.state.mode === "unavailable" + ? t("newSession.gitCheckUnavailable") + : t("newSession.runsDirectlyNote")} +
`} +
+
+ `; +} diff --git a/ui/src/pages/new-session/draft-place-browser.ts b/ui/src/pages/new-session/draft-place-browser.ts index f4d0bed00551..f03b4108b93e 100644 --- a/ui/src/pages/new-session/draft-place-browser.ts +++ b/ui/src/pages/new-session/draft-place-browser.ts @@ -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(); } diff --git a/ui/src/pages/new-session/draft-place-state.ts b/ui/src/pages/new-session/draft-place-state.ts index 57166f7b1188..08485c63df6b 100644 --- a/ui/src/pages/new-session/draft-place-state.ts +++ b/ui/src/pages/new-session/draft-place-state.ts @@ -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("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; - } } diff --git a/ui/src/pages/new-session/draft-repository-state.ts b/ui/src/pages/new-session/draft-repository-state.ts new file mode 100644 index 000000000000..66d796728f08 --- /dev/null +++ b/ui/src/pages/new-session/draft-repository-state.ts @@ -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("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(); + }); + } +} diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index ee0b02fc5a2b..13fff2db4a2f 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -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(); - 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( - ".new-session-page__select--agent wa-dropdown", - ); - if (dropdown) { - dropdown.open = false; - } - } - - private closeOpenDropdowns() { - for (const dropdown of this.querySelectorAll( - "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() { diff --git a/ui/src/pages/new-session/new-session-runtime.ts b/ui/src/pages/new-session/new-session-runtime.ts new file mode 100644 index 000000000000..17c1e0f2620f --- /dev/null +++ b/ui/src/pages/new-session/new-session-runtime.ts @@ -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(); + 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( + ".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(selector)) { + menu.open = false; + } + } +} diff --git a/ui/src/pages/new-session/place-picker.test.ts b/ui/src/pages/new-session/place-picker.test.ts deleted file mode 100644 index 7ba41de4131e..000000000000 --- a/ui/src/pages/new-session/place-picker.test.ts +++ /dev/null @@ -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[0]; - -function placeParams(overrides: Partial = {}): 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(".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("[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('[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('[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('[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); - }); -}); diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts deleted file mode 100644 index 54f2772e4d8a..000000000000 --- a/ui/src/pages/new-session/place-picker.ts +++ /dev/null @@ -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``; - - return html` - - - - - ${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` -
-
${t("newSession.folder")}
- ${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} -
${t("newSession.projects")}
- - ${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` -
- ${t("newSession.githubProjects")} -
- ${params.projectSearchCredential === "missing" - ? html`
- ${t("newSession.githubTokenHint")} -
` - : nothing} - ${params.projectSearchLoading - ? html`
- ${t("common.loading")} -
` - : nothing} - ${params.projectSearchError - ? html`` - : 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`
- ${t("newSession.cloningProject")} -
` - : nothing} - ${params.projectCloneError - ? html`` - : nothing} - ${params.projects.length === 0 && params.canWrite && !params.isAdmin - ? html`
- ${t("newSession.projectsAdminHint")} -
` - : nothing} - ${recents.length > 0 - ? html` -
${t("newSession.recentFolders")}
- ${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` - ${browseButton} - ` - : browseButton} - ${params.showDestinations - ? html` -
${t("newSession.thisGateway")}
- ${renderSessionMenuItem( - { - value: "gateway", - label: gatewayLabel, - icon: icons.monitor, - checked: !params.execNode && !params.cloudProfileId, - onSelect: () => params.onSelectExecNode(""), - }, - params.submitting, - )} - ${deviceNodes.length > 0 - ? html` -
- ${t("newSession.yourDevices")} -
- ${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` -
${t("newSession.cloud")}
- ${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`
- ${t("newSession.cloudSyncsFolder", { - folder: folderDisplayName(params.syncFolder), - })} -
` - : nothing} - ` - : nothing} - ` - : nothing} - ${!params.execNode && params.worktreeVisible - ? html` - - ${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` - - - ` - : nothing} - ` - : nothing} - ${params.showDestinations - ? nothing - : html`
- ${t("newSession.runsOn", { place: gatewayLabel })} -
`} - ${params.isAdmin - ? renderConnectMachineMenuItem({ - disabled: params.submitting || params.pendingCloud, - onSelect: params.onConnectMachine, - }) - : nothing} -
- `} -
- `; -} diff --git a/ui/src/pages/new-session/preferences.test.ts b/ui/src/pages/new-session/preferences.test.ts index f30cdb1b6c6b..686ee2dec7a6 100644 --- a/ui/src/pages/new-session/preferences.test.ts +++ b/ui/src/pages/new-session/preferences.test.ts @@ -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(); }); diff --git a/ui/src/pages/new-session/preferences.ts b/ui/src/pages/new-session/preferences.ts index 43629371c4aa..78495f0f5d8e 100644 --- a/ui/src/pages/new-session/preferences.ts +++ b/ui/src/pages/new-session/preferences.ts @@ -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; diff --git a/ui/src/pages/new-session/project-chip.test.ts b/ui/src/pages/new-session/project-chip.test.ts new file mode 100644 index 000000000000..dc7a7f6eedb4 --- /dev/null +++ b/ui/src/pages/new-session/project-chip.test.ts @@ -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); + }); +}); diff --git a/ui/src/pages/new-session/project-chip.ts b/ui/src/pages/new-session/project-chip.ts new file mode 100644 index 000000000000..101e998cc3e9 --- /dev/null +++ b/ui/src/pages/new-session/project-chip.ts @@ -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` + + `; + + return html` + + + + + ${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` +
+ ${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} + + ${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` +
+ ${t("newSession.githubProjects")} +
+ ${params.projectSearchCredentialMissing + ? html`
+ ${t("newSession.githubTokenHint")} +
` + : nothing} + ${params.projectSearchLoading + ? html`
+ ${t("common.loading")} +
` + : nothing} + ${params.projectSearchError + ? html`` + : 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`
+ ${t("newSession.cloningProject")} +
` + : nothing} + ${params.projectCloneError + ? html`` + : nothing} + ${params.projects.length === 0 && params.canWrite && !params.isAdmin + ? html`
+ ${t("newSession.projectsAdminHint")} +
` + : nothing} + ` + : nothing} + ${params.state.recents.length > 0 + ? html` +
${t("newSession.recentFolders")}
+ ${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` + ${browseButton} + ` + : browseButton} +
+ `} +
+ `; +} diff --git a/ui/src/pages/new-session/where-chip.test.ts b/ui/src/pages/new-session/where-chip.test.ts new file mode 100644 index 000000000000..5396bb1bfac3 --- /dev/null +++ b/ui/src/pages/new-session/where-chip.test.ts @@ -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); + } + }); +}); diff --git a/ui/src/pages/new-session/where-chip.ts b/ui/src/pages/new-session/where-chip.ts new file mode 100644 index 000000000000..ab5ff2312ddd --- /dev/null +++ b/ui/src/pages/new-session/where-chip.ts @@ -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; + 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` + + + + +
+ ${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` +
${t("newSession.yourDevices")}
+ ${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` +
${t("newSession.cloud")}
+ ${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} +
+
+ `; +} diff --git a/ui/src/styles/new-session.css b/ui/src/styles/new-session.css index b475ec9c6c59..27f0515a7886 100644 --- a/ui/src/styles/new-session.css +++ b/ui/src/styles/new-session.css @@ -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;