diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 7abe82a23b0a..f0c1263bc23d 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -1053,6 +1053,9 @@ async function createChatPickerScenario(): Promise 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 "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 ], 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 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 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, diff --git a/ui/src/e2e/new-session-page.e2e.test.ts b/ui/src/e2e/new-session-page.e2e.test.ts index de001e3c4c2b..19a136789d1f 100644 --- a/ui/src/e2e/new-session-page.e2e.test.ts +++ b/ui/src/e2e/new-session-page.e2e.test.ts @@ -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(); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 6d88c334bc5e..f7801f511b61 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/new-session/cloud-target.ts b/ui/src/pages/new-session/cloud-target.ts index 14c9c5b73a0a..4caf0ec9c874 100644 --- a/ui/src/pages/new-session/cloud-target.ts +++ b/ui/src/pages/new-session/cloud-target.ts @@ -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} ${params.label} + ${params.sub ? html`${params.sub}` : nothing} `; } diff --git a/ui/src/pages/new-session/composer.ts b/ui/src/pages/new-session/composer.ts index b3865ed9f92c..dab79ccafe94 100644 --- a/ui/src/pages/new-session/composer.ts +++ b/ui/src/pages/new-session/composer.ts @@ -32,6 +32,15 @@ type NewSessionComposerOptions = { onSubmit: () => void; }; +export function renderDraftError(message: string) { + return html` + + `; +} + function handleComposerKeydown(event: KeyboardEvent, options: NewSessionComposerOptions) { if ( options.submitting || diff --git a/ui/src/pages/new-session/discovery.ts b/ui/src/pages/new-session/discovery.ts index 0eaadc8c3c15..f040a7bc2176 100644 --- a/ui/src/pages/new-session/discovery.ts +++ b/ui/src/pages/new-session/discovery.ts @@ -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"), diff --git a/ui/src/pages/new-session/gateway-name-discovery.ts b/ui/src/pages/new-session/gateway-name-discovery.ts new file mode 100644 index 000000000000..9b11226d1149 --- /dev/null +++ b/ui/src/pages/new-session/gateway-name-discovery.ts @@ -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("system.info", {}); + if (requestId === this.requestToken) { + this.update(normalizeOptionalString(result.machineName) ?? shortHostname(result.hostname)); + } + } catch { + if (requestId === this.requestToken) { + this.update(""); + } + } + } +} diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index dde97b5eff5c..4ec48dbe9833 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -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` - - `; -} - 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, diff --git a/ui/src/pages/new-session/place-labels.test.ts b/ui/src/pages/new-session/place-labels.test.ts new file mode 100644 index 000000000000..98495945304b --- /dev/null +++ b/ui/src/pages/new-session/place-labels.test.ts @@ -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; + +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", + ]); + }); +}); diff --git a/ui/src/pages/new-session/place-labels.ts b/ui/src/pages/new-session/place-labels.ts new file mode 100644 index 000000000000..99f575327101 --- /dev/null +++ b/ui/src/pages/new-session/place-labels.ts @@ -0,0 +1,42 @@ +import type { DraftNode } from "./discovery.ts"; + +export function disambiguate( + items: readonly T[], + label: (item: T) => string, + candidates: ReadonlyArray<(item: T) => string | undefined>, +): Array { + const groups = new Map(); + for (const [index, item] of items.entries()) { + const key = label(item); + groups.set(key, [...(groups.get(key) ?? []), index]); + } + + const suffixes: Array = 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; +} diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts index 50ad814af8ea..e0334fad3473 100644 --- a/ui/src/pages/new-session/place-picker.ts +++ b/ui/src/pages/new-session/place-picker.ts @@ -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` @@ -205,11 +246,7 @@ export function renderPlaceSelect(params: { @click=${params.onGuardTransition} > ${params.cloudProfileId ? icons.server : params.execNode ? nodeIcon : icons.folder} ${label} ${params.worktree @@ -262,18 +299,12 @@ export function renderPlaceSelect(params: { ${recents.length > 0 ? html`
${t("newSession.recentFolders")}
- ${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`
- ${t("newSession.runsOn", { place: t("newSession.gateway") })} + ${t("newSession.runsOn", { place: gatewayLabel })}
`} `} diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 5aac9509ea58..6b405266a09e 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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);