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:
Peter Steinberger
2026-07-21 00:12:27 -07:00
committed by GitHub
parent 29a69e6508
commit dd6e366d94
12 changed files with 516 additions and 31 deletions

View File

@@ -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,

View File

@@ -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();

View File

@@ -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",

View File

@@ -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>
`;
}

View File

@@ -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 ||

View File

@@ -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"),

View 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("");
}
}
}
}

View File

@@ -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,

View 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",
]);
});
});

View 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;
}

View File

@@ -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>
`}

View File

@@ -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);