mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:21:35 +00:00
improve(ui): show real machine identity in the place picker (#112162)
* improve(ui): show real machine identity in the place picker The place picker labeled the gateway destination with a hardcoded 'Gateway · local' and let duplicate device names collide. The gateway destination now shows the host's machine name via the existing system.info method (falling back when unavailable), node rows carry platform/model/IP facts as tooltips, and duplicate labels — same-named devices and same-basename recent folders — get a deterministic muted suffix chosen collision-only (model, then IP, then id; parent folder, then path, then node facts for recents). IP addresses never render inline by default. Phone-family nodes get a phone chip icon. * fix(ui): appease prod-type and no-shadow gates in place identity helpers
This commit is contained in:
committed by
GitHub
parent
29a69e6508
commit
dd6e366d94
@@ -1053,6 +1053,9 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
|
||||
status: "failed",
|
||||
lastRunError: "Model out of credits: openai/gpt-5.6",
|
||||
}),
|
||||
sessionRow("agent:main:work-openclaw", "OpenClaw work checkout", baseTime - 85_000, {
|
||||
execCwd: "/Users/peter/Work/openclaw",
|
||||
}),
|
||||
mainChildRow,
|
||||
sessionRow("agent:main:home-server", "Home server migration", baseTime - 240_000, {
|
||||
execCwd: "/Users/peter/Projects",
|
||||
@@ -1131,6 +1134,7 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
|
||||
"question.list",
|
||||
"sessions.diff",
|
||||
"sessions.files.set",
|
||||
"system.info",
|
||||
],
|
||||
historyMessages: buildScrollableChatHistory(baseTime),
|
||||
// Lights up the footer facepile and who's-online roster; the email-only
|
||||
@@ -1142,6 +1146,20 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
|
||||
],
|
||||
methodResponses: {
|
||||
...buildBackgroundTasksMock(baseTime),
|
||||
"system.info": {
|
||||
machineName: "Peters-Mac-Studio",
|
||||
hostname: "peters-mac-studio.local",
|
||||
platform: "darwin",
|
||||
release: "25.0.0",
|
||||
arch: "arm64",
|
||||
osLabel: "macOS 26.5",
|
||||
nodeVersion: "24.15.0",
|
||||
pid: 4242,
|
||||
uptimeMs: 86_400_000,
|
||||
cpuCount: 16,
|
||||
memoryTotalBytes: 68_719_476_736,
|
||||
memoryFreeBytes: 34_359_738_368,
|
||||
},
|
||||
"fs.listDir": {
|
||||
cases: [
|
||||
{
|
||||
@@ -1404,6 +1422,9 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
|
||||
nodeId: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
|
||||
displayName: "Mac Studio",
|
||||
platform: "darwin",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "Mac14,12",
|
||||
remoteIp: "192.168.1.11",
|
||||
version: "2026.6.11",
|
||||
connected: true,
|
||||
paired: true,
|
||||
@@ -1423,8 +1444,11 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
|
||||
nodeId: "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0",
|
||||
displayName: "Mac Studio",
|
||||
platform: "darwin",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "Mac15,14",
|
||||
remoteIp: "192.168.1.12",
|
||||
version: "2026.6.10",
|
||||
connected: false,
|
||||
connected: true,
|
||||
paired: true,
|
||||
approvalState: "approved",
|
||||
lastSeenAtMs: baseTime - 82_800_000,
|
||||
|
||||
@@ -773,6 +773,166 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses advertised system info for Gateway place labels", async () => {
|
||||
const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
workspace: WORKSPACE,
|
||||
workspaceGit: true,
|
||||
featureMethods: ["chat.metadata", "chat.startup", "system.info"],
|
||||
methodResponses: {
|
||||
"system.info": {
|
||||
machineName: "Peters-Mac-Studio",
|
||||
hostname: "peters-mac-studio.local",
|
||||
platform: "darwin",
|
||||
},
|
||||
"node.list": {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "macbook",
|
||||
displayName: "MacBook",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"environments.list": { environments: [], profiles: [] },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
await gateway.waitForRequest("system.info");
|
||||
const trigger = page.locator("#new-session-place-trigger");
|
||||
await expect
|
||||
.poll(() => trigger.locator(".new-session-page__trigger-label").textContent())
|
||||
.toBe("openclaw · Gateway · Peters-Mac-Studio");
|
||||
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();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("input.new-session-page__browser-path").getAttribute("placeholder"),
|
||||
)
|
||||
.toBe("Gateway · Peters-Mac-Studio");
|
||||
|
||||
await gateway.setMethodResponse("node.list", { nodes: [] });
|
||||
const nodeRequests = (await gateway.getRequests("node.list")).length;
|
||||
await replaceGatewayClient(page);
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("node.list")).length)
|
||||
.toBeGreaterThan(nodeRequests);
|
||||
await expect
|
||||
.poll(() => trigger.locator(".new-session-page__trigger-label").textContent())
|
||||
.toBe("openclaw");
|
||||
await trigger.click();
|
||||
await place.getByText("Runs on Gateway · Peters-Mac-Studio", { exact: true }).waitFor();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("disambiguates duplicate node names without changing the selected chip", async () => {
|
||||
const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
workspace: WORKSPACE,
|
||||
workspaceGit: true,
|
||||
methodResponses: {
|
||||
"node.list": {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "11111111aaaaaaaa",
|
||||
displayName: "Mac Studio",
|
||||
platform: "darwin",
|
||||
modelIdentifier: "Mac14,12",
|
||||
remoteIp: "192.168.1.11",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
},
|
||||
{
|
||||
nodeId: "22222222bbbbbbbb",
|
||||
displayName: "Mac Studio",
|
||||
platform: "darwin",
|
||||
modelIdentifier: "Mac15,14",
|
||||
remoteIp: "192.168.1.12",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"environments.list": { environments: [], profiles: [] },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
await gateway.waitForRequest("node.list");
|
||||
const trigger = page.locator("#new-session-place-trigger");
|
||||
await trigger.click();
|
||||
const first = page.locator('[data-value="node:11111111aaaaaaaa"]');
|
||||
const second = page.locator('[data-value="node:22222222bbbbbbbb"]');
|
||||
await expect.poll(() => first.locator(".session-menu__sub").textContent()).toBe("Mac14,12");
|
||||
await expect.poll(() => second.locator(".session-menu__sub").textContent()).toBe("Mac15,14");
|
||||
expect(await first.getAttribute("title")).toContain("192.168.1.11");
|
||||
expect(await second.getAttribute("title")).toContain("192.168.1.12");
|
||||
await second.click();
|
||||
await expect
|
||||
.poll(() => trigger.locator(".new-session-page__trigger-label").textContent())
|
||||
.toBe("Agent workspace · Mac Studio");
|
||||
expect(await trigger.textContent()).not.toContain("Mac15,14");
|
||||
expect(await trigger.textContent()).not.toContain("192.168.1.12");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("disambiguates duplicate recent basenames and applies the selected path", async () => {
|
||||
const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
workspace: WORKSPACE,
|
||||
workspaceGit: true,
|
||||
methodResponses: {
|
||||
"node.list": { nodes: [] },
|
||||
"environments.list": { environments: [], profiles: [] },
|
||||
"sessions.list": {
|
||||
count: 2,
|
||||
defaults: SESSION_LIST_DEFAULTS,
|
||||
path: "",
|
||||
sessions: [
|
||||
{ key: "agent:main:a", kind: "direct", updatedAt: 2, execCwd: "/a/openclaw" },
|
||||
{ key: "agent:main:b", kind: "direct", updatedAt: 1, execCwd: "/b/openclaw" },
|
||||
],
|
||||
ts: Date.now(),
|
||||
},
|
||||
"sessions.create": { key: "agent:main:recent-collision" },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
await gateway.waitForRequest("node.list");
|
||||
const trigger = page.locator("#new-session-place-trigger");
|
||||
await trigger.click();
|
||||
const first = page.locator('[data-value="recent::/a/openclaw"]');
|
||||
const second = page.locator('[data-value="recent::/b/openclaw"]');
|
||||
await expect.poll(() => first.locator(".session-menu__sub").textContent()).toBe("a");
|
||||
await expect.poll(() => second.locator(".session-menu__sub").textContent()).toBe("b");
|
||||
await second.click();
|
||||
await page.locator(".new-session-page__message").fill("continue in work checkout");
|
||||
await page.getByRole("button", { name: "Start thread" }).click();
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
expect(create.params).toMatchObject({
|
||||
cwd: "/b/openclaw",
|
||||
message: "continue in work checkout",
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies a recent folder and node as one place", async () => {
|
||||
const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -484,6 +484,7 @@ export const en: TranslationMap = {
|
||||
agent: "Agent",
|
||||
where: "Where",
|
||||
gateway: "Gateway · local",
|
||||
gatewayNamed: "Gateway · {name}",
|
||||
cloudWorker: "Cloud · {profile}",
|
||||
cloudWorkerProvider: "Cloud worker provider: {provider}",
|
||||
cloudRequiresWorktree: "Cloud workers require a managed worktree",
|
||||
|
||||
@@ -432,6 +432,7 @@ export async function startCloudInitialTurn(
|
||||
type SessionMenuItemOptions = {
|
||||
value: string;
|
||||
label: string;
|
||||
sub?: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
@@ -455,6 +456,7 @@ export function renderSessionMenuItem(params: SessionMenuItemOptions, submitting
|
||||
>${params.checked ? icons.check : nothing}</span
|
||||
>
|
||||
<span class="session-menu__text">${params.label}</span>
|
||||
${params.sub ? html`<span class="session-menu__sub">${params.sub}</span>` : nothing}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@ type NewSessionComposerOptions = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function renderDraftError(message: string) {
|
||||
return html`
|
||||
<div class="callout danger new-session-page__error new-session-page__alert" role="alert">
|
||||
<span class="new-session-page__alert-icon" aria-hidden="true">${icons.alertTriangle}</span>
|
||||
<span class="callout__content new-session-page__alert-message">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function handleComposerKeydown(event: KeyboardEvent, options: NewSessionComposerOptions) {
|
||||
if (
|
||||
options.submitting ||
|
||||
|
||||
@@ -10,6 +10,10 @@ export type DraftBranches = {
|
||||
export type DraftNode = {
|
||||
nodeId: string;
|
||||
displayName: string;
|
||||
platform?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
connected: boolean;
|
||||
canExec: boolean;
|
||||
canBrowse: boolean;
|
||||
@@ -29,6 +33,10 @@ export function readDraftNodes(value: unknown): DraftNode[] {
|
||||
const node = raw as {
|
||||
nodeId?: unknown;
|
||||
displayName?: unknown;
|
||||
platform?: unknown;
|
||||
deviceFamily?: unknown;
|
||||
modelIdentifier?: unknown;
|
||||
remoteIp?: unknown;
|
||||
connected?: unknown;
|
||||
commands?: unknown;
|
||||
};
|
||||
@@ -45,6 +53,10 @@ export function readDraftNodes(value: unknown): DraftNode[] {
|
||||
{
|
||||
nodeId,
|
||||
displayName: normalizeOptionalString(node.displayName) ?? nodeId,
|
||||
platform: normalizeOptionalString(node.platform),
|
||||
deviceFamily: normalizeOptionalString(node.deviceFamily),
|
||||
modelIdentifier: normalizeOptionalString(node.modelIdentifier),
|
||||
remoteIp: normalizeOptionalString(node.remoteIp),
|
||||
connected,
|
||||
canExec,
|
||||
canBrowse: canExec && commands.includes("fs.listDir"),
|
||||
|
||||
46
ui/src/pages/new-session/gateway-name-discovery.ts
Normal file
46
ui/src/pages/new-session/gateway-name-discovery.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
|
||||
|
||||
function shortHostname(value: unknown): string {
|
||||
return normalizeOptionalString(value)?.split(".", 1)[0] ?? "";
|
||||
}
|
||||
|
||||
export class GatewayNameDiscovery {
|
||||
private requestToken = 0;
|
||||
|
||||
constructor(
|
||||
private readonly snapshot: () => ApplicationContext["gateway"]["snapshot"] | undefined,
|
||||
private readonly update: (name: string) => void,
|
||||
) {}
|
||||
|
||||
invalidate() {
|
||||
this.requestToken += 1;
|
||||
this.update("");
|
||||
}
|
||||
|
||||
async load() {
|
||||
const requestId = ++this.requestToken;
|
||||
const snapshot = this.snapshot();
|
||||
const client = snapshot?.client;
|
||||
if (
|
||||
!snapshot?.connected ||
|
||||
!client ||
|
||||
isGatewayMethodAdvertised(snapshot, "system.info") !== true
|
||||
) {
|
||||
this.update("");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await client.request<SystemInfoResult>("system.info", {});
|
||||
if (requestId === this.requestToken) {
|
||||
this.update(normalizeOptionalString(result.machineName) ?? shortHostname(result.hostname));
|
||||
}
|
||||
} catch {
|
||||
if (requestId === this.requestToken) {
|
||||
this.update("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { applicationContext, type ApplicationContext } from "../../app/context.t
|
||||
import { beginNativeWindowDragFromTopInset } from "../../app/native-window-drag.ts";
|
||||
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
|
||||
import { loadSettings } from "../../app/settings.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import "../../components/web-awesome-popover.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
@@ -26,7 +25,7 @@ import * as catalog from "./catalog-target.ts";
|
||||
import { CloudProfileDiscovery, selectProfiles } from "./cloud-profile-discovery.ts";
|
||||
import { PendingCloudRecoveryState, resolveScope } from "./cloud-recovery-state.ts";
|
||||
import { advanceCloudDraftSession } from "./cloud-submit.ts";
|
||||
import { renderNewSessionDraftComposer } from "./composer.ts";
|
||||
import { renderDraftError, renderNewSessionDraftComposer } from "./composer.ts";
|
||||
import { buildDraftSessionCreateParams, isWorktreeNameValid } from "./create-params.ts";
|
||||
import {
|
||||
type BrowserTarget,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
type DraftNode,
|
||||
readDraftNodes,
|
||||
} from "./discovery.ts";
|
||||
import { GatewayNameDiscovery } from "./gateway-name-discovery.ts";
|
||||
import type { NewSessionRouteData } from "./location.ts";
|
||||
import { NewSessionModelControl } from "./model-control.ts";
|
||||
import { isAbsolutePath } from "./path.ts";
|
||||
@@ -44,15 +44,6 @@ import { renderAgentSelect } from "./target-controls.ts";
|
||||
|
||||
const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const;
|
||||
|
||||
function renderDraftError(message: string) {
|
||||
return html`
|
||||
<div class="callout danger new-session-page__error new-session-page__alert" role="alert">
|
||||
<span class="new-session-page__alert-icon" aria-hidden="true">${icons.alertTriangle}</span>
|
||||
<span class="callout__content new-session-page__alert-message">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
class NewSessionPage extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) data: NewSessionRouteData | undefined;
|
||||
|
||||
@@ -67,6 +58,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
@state() private branches: DraftBranches | null = null;
|
||||
@state() private branchesLoading = false;
|
||||
@state() private nodes: DraftNode[] = [];
|
||||
@state() private gatewayName = "";
|
||||
@state() private execNode = "";
|
||||
@state() private cloudProfiles: DraftCloudProfile[] = [];
|
||||
@state() private cloudProfilesHydrated = false;
|
||||
@@ -95,6 +87,10 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
private folderSelectedByUser = false;
|
||||
private submitRequestToken = 0;
|
||||
private nodesRequestToken = 0;
|
||||
private readonly gatewayNameDiscovery = new GatewayNameDiscovery(
|
||||
() => this.context?.gateway.snapshot,
|
||||
(name) => (this.gatewayName = name),
|
||||
);
|
||||
private readonly pendingCloud = new PendingCloudRecoveryState();
|
||||
private readonly cloudProfileDiscovery = new CloudProfileDiscovery({
|
||||
snapshot: () => ({
|
||||
@@ -199,6 +195,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
if (becameConnected) {
|
||||
this.gatewayConnectionEpoch += 1;
|
||||
this.retryPendingCatalogTarget();
|
||||
void this.gatewayNameDiscovery.load();
|
||||
}
|
||||
void this.cloudProfileDiscovery.load();
|
||||
}
|
||||
@@ -207,6 +204,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
private invalidateGatewayDiscovery(resetHostSelection: boolean) {
|
||||
this.nodesRequestToken += 1;
|
||||
this.nodesHydrated = false;
|
||||
this.gatewayNameDiscovery.invalidate();
|
||||
this.cloudProfileDiscovery.invalidate();
|
||||
this.branchesRequestToken += 1;
|
||||
this.branchesLoading = false;
|
||||
@@ -1218,6 +1216,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
workspace: this.workspacePath(),
|
||||
sessions: this.context?.sessions.state.result?.sessions ?? [],
|
||||
execNodes: this.isAdmin() ? execNodes : [],
|
||||
gatewayName: this.gatewayName,
|
||||
cloudProfiles: this.isAdmin() ? cloudProfiles : [],
|
||||
cloudProfileId: this.cloudProfileId,
|
||||
execNode: this.execNode,
|
||||
|
||||
147
ui/src/pages/new-session/place-labels.test.ts
Normal file
147
ui/src/pages/new-session/place-labels.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DraftNode } from "./discovery.ts";
|
||||
import { disambiguate } from "./place-labels.ts";
|
||||
|
||||
type LabelFixture = Pick<DraftNode, "nodeId" | "displayName" | "modelIdentifier" | "remoteIp">;
|
||||
|
||||
const nodeCandidates = [
|
||||
(node: LabelFixture) => node.modelIdentifier,
|
||||
(node: LabelFixture) => node.remoteIp,
|
||||
(node: LabelFixture) => node.nodeId.slice(0, 8),
|
||||
];
|
||||
|
||||
describe("disambiguate", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "leaves unique labels alone",
|
||||
items: [
|
||||
{ nodeId: "11111111", displayName: "Mac Studio", modelIdentifier: "Mac14,12" },
|
||||
{ nodeId: "22222222", displayName: "MacBook", modelIdentifier: "Mac15,3" },
|
||||
],
|
||||
expected: [undefined, undefined],
|
||||
},
|
||||
{
|
||||
name: "uses models when they differ",
|
||||
items: [
|
||||
{ nodeId: "11111111", displayName: "Mac Studio", modelIdentifier: "Mac14,12" },
|
||||
{ nodeId: "22222222", displayName: "Mac Studio", modelIdentifier: "Mac15,14" },
|
||||
],
|
||||
expected: ["Mac14,12", "Mac15,14"],
|
||||
},
|
||||
{
|
||||
name: "uses IPs when models tie",
|
||||
items: [
|
||||
{
|
||||
nodeId: "11111111",
|
||||
displayName: "Mac Studio",
|
||||
modelIdentifier: "Mac14,12",
|
||||
remoteIp: "192.168.1.11",
|
||||
},
|
||||
{
|
||||
nodeId: "22222222",
|
||||
displayName: "Mac Studio",
|
||||
modelIdentifier: "Mac14,12",
|
||||
remoteIp: "192.168.1.12",
|
||||
},
|
||||
],
|
||||
expected: ["192.168.1.11", "192.168.1.12"],
|
||||
},
|
||||
{
|
||||
name: "advances past a partly distinct model candidate",
|
||||
items: [
|
||||
{
|
||||
nodeId: "11111111",
|
||||
displayName: "Mac Studio",
|
||||
modelIdentifier: "Model A",
|
||||
remoteIp: "192.168.1.11",
|
||||
},
|
||||
{
|
||||
nodeId: "22222222",
|
||||
displayName: "Mac Studio",
|
||||
modelIdentifier: "Model A",
|
||||
remoteIp: "192.168.1.12",
|
||||
},
|
||||
{
|
||||
nodeId: "33333333",
|
||||
displayName: "Mac Studio",
|
||||
modelIdentifier: "Model B",
|
||||
remoteIp: "192.168.1.13",
|
||||
},
|
||||
],
|
||||
expected: ["192.168.1.11", "192.168.1.12", "192.168.1.13"],
|
||||
},
|
||||
{
|
||||
name: "falls back to short IDs when facts are missing",
|
||||
items: [
|
||||
{ nodeId: "11111111aaaa", displayName: "Mac Studio" },
|
||||
{ nodeId: "22222222bbbb", displayName: "Mac Studio" },
|
||||
],
|
||||
expected: ["11111111", "22222222"],
|
||||
},
|
||||
{
|
||||
name: "uses the final fallback for a member missing the selected fact",
|
||||
items: [
|
||||
{ nodeId: "11111111aaaa", displayName: "Mac Studio", modelIdentifier: "Mac14,12" },
|
||||
{ nodeId: "22222222bbbb", displayName: "Mac Studio", modelIdentifier: "Mac15,14" },
|
||||
{ nodeId: "33333333cccc", displayName: "Mac Studio" },
|
||||
],
|
||||
expected: ["Mac14,12", "Mac15,14", "33333333"],
|
||||
},
|
||||
{
|
||||
name: "uses the last candidate when none is fully distinct",
|
||||
items: [
|
||||
{
|
||||
nodeId: "11111111aaaa",
|
||||
displayName: "Mac Studio",
|
||||
modelIdentifier: "Mac14,12",
|
||||
remoteIp: "192.168.1.11",
|
||||
},
|
||||
{
|
||||
nodeId: "11111111bbbb",
|
||||
displayName: "Mac Studio",
|
||||
modelIdentifier: "Mac14,12",
|
||||
remoteIp: "192.168.1.11",
|
||||
},
|
||||
],
|
||||
expected: ["11111111", "11111111"],
|
||||
},
|
||||
])("$name", ({ items, expected }) => {
|
||||
expect(disambiguate(items, (item) => item.displayName, nodeCandidates)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("uses parent folders, then full paths, for recent basename collisions", () => {
|
||||
const items: Array<{ folder: string; label: string; execNode?: string }> = [
|
||||
{ folder: "/a/openclaw", label: "openclaw" },
|
||||
{ folder: "/b/openclaw", label: "openclaw" },
|
||||
{ folder: "/one/shared/openclaw", label: "openclaw · Mac Studio" },
|
||||
{ folder: "/two/shared/openclaw", label: "openclaw · Mac Studio" },
|
||||
{
|
||||
folder: "/same/openclaw",
|
||||
execNode: "11111111aaaaaaaa",
|
||||
label: "openclaw · Duplicate Mac",
|
||||
},
|
||||
{
|
||||
folder: "/same/openclaw",
|
||||
execNode: "22222222bbbbbbbb",
|
||||
label: "openclaw · Duplicate Mac",
|
||||
},
|
||||
];
|
||||
expect(
|
||||
disambiguate(items, (item) => item.label, [
|
||||
(item) => item.folder.split("/").at(-2),
|
||||
(item) => item.folder,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
(item) => `${item.folder}${item.execNode ? ` · ${item.execNode.slice(0, 8)}` : ""}`,
|
||||
]),
|
||||
).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"/one/shared/openclaw",
|
||||
"/two/shared/openclaw",
|
||||
"/same/openclaw · 11111111",
|
||||
"/same/openclaw · 22222222",
|
||||
]);
|
||||
});
|
||||
});
|
||||
42
ui/src/pages/new-session/place-labels.ts
Normal file
42
ui/src/pages/new-session/place-labels.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { DraftNode } from "./discovery.ts";
|
||||
|
||||
export function disambiguate<T>(
|
||||
items: readonly T[],
|
||||
label: (item: T) => string,
|
||||
candidates: ReadonlyArray<(item: T) => string | undefined>,
|
||||
): Array<string | undefined> {
|
||||
const groups = new Map<string, number[]>();
|
||||
for (const [index, item] of items.entries()) {
|
||||
const key = label(item);
|
||||
groups.set(key, [...(groups.get(key) ?? []), index]);
|
||||
}
|
||||
|
||||
const suffixes: Array<string | undefined> = items.map(() => undefined);
|
||||
for (const indices of groups.values()) {
|
||||
if (indices.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const fallback = candidates.at(-1);
|
||||
if (!fallback) {
|
||||
continue;
|
||||
}
|
||||
const candidate =
|
||||
candidates.find((option) => {
|
||||
const values = indices.map((index) => option(items[index]!) ?? fallback(items[index]!));
|
||||
return (
|
||||
values.every((value) => value !== undefined) && new Set(values).size === indices.length
|
||||
);
|
||||
}) ?? fallback;
|
||||
for (const index of indices) {
|
||||
suffixes[index] = candidate(items[index]!) ?? fallback(items[index]!);
|
||||
}
|
||||
}
|
||||
return suffixes;
|
||||
}
|
||||
|
||||
export function nodeTooltip(node: DraftNode): string | undefined {
|
||||
const facts = [node.platform, node.modelIdentifier, node.remoteIp].filter(
|
||||
(fact): fact is string => fact !== undefined,
|
||||
);
|
||||
return facts.length > 0 ? facts.join(" · ") : undefined;
|
||||
}
|
||||
@@ -5,8 +5,19 @@ import { t } from "../../i18n/index.ts";
|
||||
import { renderCloudProfileMenuItems, renderSessionMenuItem } from "./cloud-target.ts";
|
||||
import type { BrowserTarget, DraftBranches, DraftCloudProfile, DraftNode } from "./discovery.ts";
|
||||
import { folderDisplayName } from "./path.ts";
|
||||
import { disambiguate, nodeTooltip } from "./place-labels.ts";
|
||||
import { recentPlaces, type RecentPlaceSource } from "./recent-places.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;
|
||||
}
|
||||
|
||||
function renderBrowseView(params: {
|
||||
listing: FsListDirResult | null;
|
||||
target: BrowserTarget;
|
||||
@@ -124,6 +135,7 @@ export function renderPlaceSelect(params: {
|
||||
workspace: string;
|
||||
sessions: readonly RecentPlaceSource[];
|
||||
execNodes: DraftNode[];
|
||||
gatewayName: string;
|
||||
cloudProfiles: DraftCloudProfile[];
|
||||
cloudProfileId: string;
|
||||
execNode: string;
|
||||
@@ -173,19 +185,48 @@ export function renderPlaceSelect(params: {
|
||||
const activeProfile = params.cloudProfiles.find(
|
||||
(profile) => profile.id === params.cloudProfileId,
|
||||
);
|
||||
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)
|
||||
: t("newSession.gateway");
|
||||
: gatewayLabel;
|
||||
const label = params.showDestinations ? `${folderLabel} · ${destinationLabel}` : folderLabel;
|
||||
const effectiveFolder = folder || params.workspace;
|
||||
const recents = params.browseAvailable
|
||||
? recentPlaces(params.sessions, { workspace: params.workspace, execNodes: params.execNodes })
|
||||
: [];
|
||||
const recentItems = recents.map((recent) => {
|
||||
const node = params.execNodes.find((candidate) => candidate.nodeId === recent.execNode);
|
||||
const recentLabel =
|
||||
params.showDestinations && node
|
||||
? `${folderDisplayName(recent.folder)} · ${node.displayName}`
|
||||
: folderDisplayName(recent.folder);
|
||||
return { ...recent, label: recentLabel, node };
|
||||
});
|
||||
const recentSuffixes = disambiguate(recentItems, (recent) => recent.label, [
|
||||
(recent) => parentFolderDisplayName(recent.folder),
|
||||
(recent) => recent.folder,
|
||||
(recent) => recent.node?.modelIdentifier,
|
||||
(recent) => recent.node?.remoteIp,
|
||||
(recent) => `${recent.folder}${recent.execNode ? ` · ${recent.execNode.slice(0, 8)}` : ""}`,
|
||||
]);
|
||||
const nodeSuffixes = disambiguate(params.execNodes, (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: t("newSession.gateway") };
|
||||
: { nodeId: "", label: gatewayLabel };
|
||||
const deviceFamily = activeNode?.deviceFamily?.toLowerCase() ?? "";
|
||||
const nodeIcon = ["iphone", "ipad", "ios", "android", "phone"].some((token) =>
|
||||
deviceFamily.includes(token),
|
||||
)
|
||||
? icons.monitorSmartphone
|
||||
: icons.monitor;
|
||||
|
||||
return html`
|
||||
<span class="new-session-page__select">
|
||||
@@ -205,11 +246,7 @@ export function renderPlaceSelect(params: {
|
||||
@click=${params.onGuardTransition}
|
||||
>
|
||||
<span class="new-session-page__target-icon" aria-hidden="true"
|
||||
>${params.cloudProfileId
|
||||
? icons.server
|
||||
: params.execNode
|
||||
? icons.monitor
|
||||
: icons.folder}</span
|
||||
>${params.cloudProfileId ? icons.server : params.execNode ? nodeIcon : icons.folder}</span
|
||||
>
|
||||
<span class="new-session-page__trigger-label">${label}</span>
|
||||
${params.worktree
|
||||
@@ -262,18 +299,12 @@ export function renderPlaceSelect(params: {
|
||||
${recents.length > 0
|
||||
? html`
|
||||
<div class="new-session-page__menu-title">${t("newSession.recentFolders")}</div>
|
||||
${recents.map((recent) => {
|
||||
const node = params.execNodes.find(
|
||||
(candidate) => candidate.nodeId === recent.execNode,
|
||||
);
|
||||
const recentLabel =
|
||||
params.showDestinations && node
|
||||
? `${folderDisplayName(recent.folder)} · ${node.displayName}`
|
||||
: folderDisplayName(recent.folder);
|
||||
${recentItems.map((recent, index) => {
|
||||
return renderSessionMenuItem(
|
||||
{
|
||||
value: `recent:${recent.execNode}:${recent.folder}`,
|
||||
label: recentLabel,
|
||||
label: recent.label,
|
||||
sub: recentSuffixes[index],
|
||||
checked: params.execNode === recent.execNode && folder === recent.folder,
|
||||
title: recent.folder,
|
||||
onSelect: () => params.onApplyFolder(recent.folder, recent.execNode),
|
||||
@@ -305,18 +336,20 @@ export function renderPlaceSelect(params: {
|
||||
${renderSessionMenuItem(
|
||||
{
|
||||
value: "gateway",
|
||||
label: t("newSession.gateway"),
|
||||
label: gatewayLabel,
|
||||
checked: !params.execNode && !params.cloudProfileId,
|
||||
onSelect: () => params.onSelectExecNode(""),
|
||||
},
|
||||
params.submitting,
|
||||
)}
|
||||
${params.execNodes.map((node) =>
|
||||
${params.execNodes.map((node, index) =>
|
||||
renderSessionMenuItem(
|
||||
{
|
||||
value: `node:${node.nodeId}`,
|
||||
label: node.displayName,
|
||||
sub: nodeSuffixes[index],
|
||||
checked: params.execNode === node.nodeId,
|
||||
title: nodeTooltip(node),
|
||||
onSelect: () => params.onSelectExecNode(node.nodeId),
|
||||
},
|
||||
params.submitting,
|
||||
@@ -419,7 +452,7 @@ export function renderPlaceSelect(params: {
|
||||
${params.showDestinations
|
||||
? nothing
|
||||
: html`<div class="new-session-page__menu-note">
|
||||
${t("newSession.runsOn", { place: t("newSession.gateway") })}
|
||||
${t("newSession.runsOn", { place: gatewayLabel })}
|
||||
</div>`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
@@ -2534,6 +2534,16 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-menu__sub {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-menu__chevron {
|
||||
display: inline-flex;
|
||||
color: var(--muted);
|
||||
|
||||
Reference in New Issue
Block a user