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