feat(ui): keep Where picker destinations live (#122531)

* feat(gateway): enrich environment summaries

* chore(sdk): refresh plugin API surface

surface drift approved: additive EnvironmentSummary enrichment (runners plan rev 2, milestone 4)

* feat(ui): keep where picker destinations live

* docs: link picker liveness milestone PR

* fix(ui): deduplicate picker locale keys

* test(ui): update picker section expectations

* fix(ssrf): narrow lookup resolver contract

* fix(gateway): publish authoritative environment facts
This commit is contained in:
Peter Steinberger
2026-08-12 05:11:46 -07:00
committed by GitHub
parent fc14a5a587
commit 40cb6f0e3b
51 changed files with 471 additions and 138 deletions
@@ -1904,6 +1904,9 @@ public struct EnvironmentSummary: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let worker: WorkerEnvironmentMetadata?
@@ -1912,6 +1915,9 @@ public struct EnvironmentSummary: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
@@ -1919,6 +1925,9 @@ public struct EnvironmentSummary: Codable, Sendable {
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.worker = worker
}
@@ -1928,6 +1937,9 @@ public struct EnvironmentSummary: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case worker
}
@@ -1956,6 +1968,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let worker: WorkerEnvironmentMetadata?
@@ -1964,6 +1979,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
@@ -1971,6 +1989,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.worker = worker
}
@@ -1980,6 +2001,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case worker
}
@@ -2008,6 +2032,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let worker: WorkerEnvironmentMetadata?
@@ -2016,6 +2043,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
@@ -2023,6 +2053,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.worker = worker
}
@@ -2032,6 +2065,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case worker
}
@@ -2076,6 +2112,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let worker: WorkerEnvironmentMetadata?
@@ -2084,6 +2123,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
@@ -2091,6 +2133,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.worker = worker
}
@@ -2100,6 +2145,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case worker
}
@@ -1 +1 @@
{"contentHash":"59fb241bbe47669d5d7f99a945a18d86f318b9af8c4c281bb11f01506c84ebbf","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"e4a7ffbb2daf39077b19f28d0dbf9267bb645be42ce18e24161966e34b16bdc7","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"b7195e12bbe582de01faacc9d3a22f5bc30e0210f7b0c03d3b6242b969f7a0be","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"517a4d6efc324c135d1761b9d8e77ed573162bcc8b662372cebdfd7c5e3083c1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"04848d6e3e592c5edf260afa6ab27452770f99ef09e110809fab0813fd1671b0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"007c23cfcc4af2cc62967750beabdb9825352b54afc3057c989c7e9179790ad0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"656e29f955e218c9c35843a876eecd769b722e64fb8ea1849f266fa9a430ffd1","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"454a8b3a3ee15a1a691970f4b5cb15878b8f2f4e166d6600bf639f57292232f3","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"efed0dfd7d5ade67b9f4549b1505e8def917262e1940c9cbe10f761ff154811b","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
{"contentHash":"aaadbf677c001d81a784acbe65ce9acb4d76794be31fecff9d2c9b8d984ec37c","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
@@ -1 +1 @@
{"contentHash":"f57838a5ab613e8e168c74cb309beef813587f5465ff80f18b8171320f579e7a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"8bafa462130e5c1da30329c7face6a483957d4a0c3cc689b1632f1974420b69f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"0b81e57bcb56659f98b52d55a1dea6f987bae23500a62d56daf4e86065929ac9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"9e02fe7d5c9f80328c7f557420ee84687954f753dfd84b19e1469eb94b8dd168","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"7375a14d1a2ead1dce9d13bc9a6c5c94dc7e809cde37860a7ace9fce480b6751","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
{"contentHash":"982f42cd1ef5594aff26969ac256e5cf2f124f409b7e020fcad5117fc8322055","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
@@ -1 +1 @@
{"contentHash":"57eaf2d515736c9a6880da9434dd6456e7744e75ac8a946709d9e5f30b166aed","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"a0b2dd06b690fe208c45c02e858934b527ed3c419f1a7ebd394423fc295cbff9","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"bb239ef24687ac9b89335858dff0008f3b861546c242e3af3545fbcae1878309","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"402dc7fb02d64dc390510abbf1eb240ccb807b1bf3e26ddaa582c9393bdfb57a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"34a3ec8d182434d7682a6ae2258a4b1797ffc20f62a7a0449ca3055ccc8c69c5","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"4b0344b65821cc88ea69e7d6e8ca37ddfd21d258035e6ba7d613748df743c761","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"38dbab19f1c23c82c20443339807822f5dfc626d42171dbb71f3322992d01097","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"ffbc6c5c809ed0ceb135ea42926e321edf4265eb96c80e0f9df536d7eb472811","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"080a0e85aaff22d1e860781d779254461b05e40e4af84b37197b9ae6281ca566","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"0ebc21dbb6435d83c96ddae60291abbf36a47292288533e1b921bf9501581895","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d83147f1ddbab6a62f7afc6ec7bbf5ec960b3abf428e6c9abe4a0f9518b0a8c7","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"}
{"contentHash":"463ab9088e5cbf0f008716d00676aaf2a4bdf0f13a6621cd42a76aac34f5b7a0","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"04ca7bf038cd8ad69f3145ebd3c774b8b27b18ed96fa8227e289abcecdbd83b5","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"}
{"contentHash":"fe35d8eebcbfbdb20fc4a1e3b903e2dc7891dc2f12feb3e6130fec5dcd605376","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"}
@@ -1 +1 @@
{"contentHash":"ecf1ca74a42f003b046e3bf2578bae03c609050c6cb16fa741f763d692937e67","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"458825733f53d1c06d468287d7059d2400202572082a8300d7806c71856498c7","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"a8e907e41641913ebb36b982bb320090197af1408a0196672c15d038883cf1e0","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"0cbd356b5509eb2bd0f744987a48e34726525e8932fe5a6bb9c7eb5884b55329","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"8d4c89bf2968217111f333c149b51bb6798dd961a62fbafeef1c19f2473e7ab6","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"f662a31a8c5db27d767489160e9d443df45190429e8f4fb9f6e184d12bac3f19","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"5772bc63ec00a1de6e76827a9a8f79c1ed61493d07f6c2bb21f50c18a806e187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"d0c70224a6b22aef9680ea81d32d071b11a2533c885dc8665080c6c00255ea4d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"9a243e9ff6e6512ef4b635ed503159631e4e000a5dbc4d7743ffd091dfb1ef1d","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"}
{"contentHash":"babd3de9e3fbbc52a328d73ae815f4df4dbc57377aa0a0ec5de08636b87ab542","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"}
@@ -1 +1 @@
{"contentHash":"ea0c884272571c2494865e709f61fd3a33e0da4e2096c301ab5d292783b923bb","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"}
{"contentHash":"d293aefd15df648be5808cdbe6194cb363cc3410e9e0ae01cf1d2162f8a475c5","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"}
@@ -1 +1 @@
{"contentHash":"382193e8ef6f05d7bc0d872abefba471c374737bcc86db3810be780b893b72b4","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"}
{"contentHash":"e843ae3223098e08b57d4af6efd78cc1693bb5ed3b7f589dee10c223d914b1bb","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"c0619235a9636dc3fc22ab5ac74e7d14a01bf9c40594ced596a17143b6eb90c4","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"}
{"contentHash":"f15daa0e828f7f32901d14f7174b31883799c1bd6c1498d4295a85cbd045eeb6","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"77efaea8a61aa4e0dccdffcf2d6e6bb4bcc758587691126c17905ffdf11ddd23","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"}
{"contentHash":"9df7566aad3ff6c1d41e693351d47e09f77f30a0b6d43a828afe7439b84fe36f","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"993018e34f2e7951c2a0412a4df0cd4be50256da04f3d6a539942c3a8168da24","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"4962f66547dd18b573f4c3ee7ad08f35f220417559437ed2653ef1e3f07fb4be","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"079ce3652967694b58caa87ed04cdaba07b3766d82c491faa9a6f974d584e18b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"326217df400252a236ce5f5e1f98adfcbbec79d3a5d068cf44fc3b7a70e0debe","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+15 -15
View File
@@ -13,21 +13,21 @@ Proposal, revision 2. Supersedes revision 1 in place (2026-08-11, operator
decision). Implementation in progress; update this table in every PR that
advances a milestone.
| # | Milestone | Status | PRs |
| --- | ---------------------------------------------------------- | ----------- | ------- |
| 0 | This plan (revision 2) | landed | #122454 |
| 1a | Naming: session copy revert | landed | #120667 |
| 1b | Naming: devices consolidation | landed | #120689 |
| 1c | Cleanup: node-pairing → device-pairing merge | not started | — |
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — |
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804 |
| 5 | Public worker ingress path | not started | — |
| 6 | Node worker provider (device runners) | not started | — |
| 7 | Bundle push consent + runner updates | not started | — |
| 8 | Stop-and-continue moves | not started | — |
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — |
| # | Milestone | Status | PRs |
| --- | ---------------------------------------------------------- | ----------- | ---------------- |
| 0 | This plan (revision 2) | landed | #122454 |
| 1a | Naming: session copy revert | landed | #120667 |
| 1b | Naming: devices consolidation | landed | #120689 |
| 1c | Cleanup: node-pairing → device-pairing merge | not started | — |
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — |
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531 |
| 5 | Public worker ingress path | not started | — |
| 6 | Node worker provider (device runners) | not started | — |
| 7 | Bundle push consent + runner updates | not started | — |
| 8 | Stop-and-continue moves | not started | — |
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — |
Revision history: revision 1 (2026-08-08) established the session/runner
vocabulary, the naming rulings, and the milestone skeleton after a
@@ -35,10 +35,9 @@ type RegistrationFetchOptions = {
const HERMETIC_PUBLIC_LOOKUP_ADDRESS = "93.184.216.34";
const hermeticPublicLookup: LookupFn = (async (_hostname: string, _options?: unknown) => ({
address: HERMETIC_PUBLIC_LOOKUP_ADDRESS,
family: 4,
})) as LookupFn;
const hermeticPublicLookup: LookupFn = async () => [
{ address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, family: 4 },
];
async function startLocalServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
+3 -4
View File
@@ -43,10 +43,9 @@ type StreamingRequest = {
const serverStops: Array<() => Promise<void>> = [];
const HERMETIC_PUBLIC_LOOKUP_ADDRESS = "93.184.216.34";
const hermeticPublicLookup: LookupFn = (async (_hostname: string, _options?: unknown) => ({
address: HERMETIC_PUBLIC_LOOKUP_ADDRESS,
family: 4,
})) as LookupFn;
const hermeticPublicLookup: LookupFn = async () => [
{ address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, family: 4 },
];
async function readRequestBody(req: IncomingMessage): Promise<string> {
let body = "";
+5 -1
View File
@@ -677,6 +677,10 @@ async function safeFetch(params: {
}
if (!hasDispatcher) {
const lookupFn: LookupFn = async (hostname) => {
const resolved = await resolveFn(hostname);
return [{ ...resolved, family: resolved.address.includes(":") ? 6 : 4 }];
};
const guarded = await fetchWithSsrFGuard({
url: currentUrl,
fetchImpl: resolveGuardedFetchImpl({
@@ -690,7 +694,7 @@ async function safeFetch(params: {
maxRedirects: MAX_SAFE_REDIRECTS,
requireHttps: true,
policy: resolveMediaSsrfPolicy(params.allowHosts),
lookupFn: resolveFn as LookupFn,
lookupFn,
retainAuthorizationRedirectHostnameAllowlist:
resolveRetainedAuthorizationRedirectHostnameAllowlist(params.authorizationAllowHosts),
auditContext: "msteams.attachment",
@@ -82,7 +82,12 @@ describe("worker environment protocol schemas", () => {
});
it("accepts worker metadata additively across summary and mutation results", () => {
const requested = workerSummary("requested");
const requested = {
...workerSummary("requested"),
platform: "linux",
sessionHost: false,
trust: "disposable",
};
const destroyedBase = workerSummary("destroyed", "unavailable");
const destroyed = {
...destroyedBase,
@@ -140,7 +145,7 @@ describe("worker environment protocol schemas", () => {
expect(
Value.Check(EnvironmentsListResultSchema, {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
profiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }],
}),
).toBe(true);
expect(
@@ -149,6 +154,12 @@ describe("worker environment protocol schemas", () => {
profiles: [{ id: "aws", providerId: "crabbox", settings: { token: "hidden" } }],
}),
).toBe(false);
expect(
Value.Check(EnvironmentsListResultSchema, {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox", trust: "temporary" }],
}),
).toBe(false);
});
it("preserves summaries without worker metadata and rejects malformed worker metadata", () => {
@@ -180,5 +191,11 @@ describe("worker environment protocol schemas", () => {
worker: { ...workerSummary("failed").worker, error: "" },
}),
).toBe(false);
expect(
Value.Check(EnvironmentSummarySchema, {
...workerSummary("ready", "available"),
trust: "temporary",
}),
).toBe(false);
});
});
@@ -14,6 +14,10 @@ export const EnvironmentStatusSchema = Type.String({
enum: ["available", "unavailable", "starting", "stopping", "error"],
});
const EnvironmentTrustSchema = Type.String({
enum: ["persistent", "disposable"],
});
/** Durable lifecycle states for plugin-provisioned worker environments. */
export const WorkerEnvironmentStateSchema = Type.Union([
Type.Literal("requested"),
@@ -65,6 +69,9 @@ function createEnvironmentSummarySchema() {
type: NonEmptyString,
label: Type.Optional(NonEmptyString),
status: EnvironmentStatusSchema,
platform: Type.Optional(NonEmptyString),
sessionHost: Type.Optional(Type.Boolean()),
trust: Type.Optional(EnvironmentTrustSchema),
capabilities: Type.Optional(Type.Array(NonEmptyString)),
worker: Type.Optional(WorkerEnvironmentMetadataSchema),
});
@@ -80,6 +87,7 @@ export const EnvironmentsListParamsSchema = closedObject({});
const WorkerEnvironmentProfileSummarySchema = closedObject({
id: NonEmptyString,
providerId: NonEmptyString,
trust: Type.Optional(EnvironmentTrustSchema),
});
/** List response containing all gateway-visible environment summaries. */
@@ -756,6 +756,9 @@ async function proveRealGatewayContracts(): Promise<void> {
type: "local",
label: "Gateway local",
status: "available",
platform: process.platform,
sessionHost: true,
trust: "persistent",
capabilities: ["agent.run", "sessions", "tools", "workspace"],
});
const gatewayEnvironment = await oc.environments.status("gateway");
+4
View File
@@ -79,6 +79,9 @@ export type EnvironmentSummary = {
type: "local" | "gateway" | "node" | "managed" | "ephemeral" | (string & {});
label?: string;
status: "available" | "unavailable" | "starting" | "stopping" | "error";
platform?: string;
sessionHost?: boolean;
trust?: "persistent" | "disposable";
capabilities?: string[];
worker?: WorkerEnvironmentMetadata;
};
@@ -91,6 +94,7 @@ export type EnvironmentCreateParams = {
export type WorkerEnvironmentProfileSummary = {
id: string;
providerId: string;
trust?: "persistent" | "disposable";
};
export type EnvironmentsListResult = {
+1 -1
View File
@@ -202,7 +202,7 @@ export async function prepareGatewayLifecycle(params: {
instanceId: session.nodeId,
reason: "connect",
});
incrementPresenceVersion();
broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion });
recordRemoteNodeInfo({
nodeId: session.nodeId,
connId: session.connId,
@@ -99,6 +99,7 @@ function workerRecord(overrides: Partial<TestWorkerRecord> = {}): TestWorkerReco
profileSnapshot: { settings: {} },
provisionOperationId: "provision:worker-1",
leaseId: "lease-1",
sharedHost: false,
desktop: null,
sshEndpoint: {
host: "worker.example.test",
@@ -208,6 +209,9 @@ describe("environment gateway methods", () => {
type: "local",
label: "Gateway local",
status: "available",
platform: process.platform,
sessionHost: true,
trust: "persistent",
capabilities: ["agent.run", "sessions", "tools", "workspace"],
},
{
@@ -215,6 +219,9 @@ describe("environment gateway methods", () => {
type: "node",
label: "Live Node",
status: "available",
platform: "ios",
sessionHost: false,
trust: "persistent",
capabilities: ["camera", "system.run"],
},
{
@@ -222,6 +229,8 @@ describe("environment gateway methods", () => {
type: "node",
label: "Offline Node",
status: "unavailable",
sessionHost: false,
trust: "persistent",
capabilities: ["camera.snap", "screen"],
},
],
@@ -254,6 +263,7 @@ describe("environment gateway methods", () => {
id: "worker-1",
type: "worker",
status: "available",
trust: "disposable",
worker: {
providerId: "static-ssh",
leaseId: "lease-1",
@@ -285,6 +295,18 @@ describe("environment gateway methods", () => {
expect(summarizeWorkerEnvironment(workerRecord({ state }), NOW).status).toBe(status);
});
it("projects trust from recorded worker isolation without guessing unknown leases", () => {
expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: true }), NOW).trust).toBe(
"persistent",
);
expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: false }), NOW).trust).toBe(
"disposable",
);
expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: null }), NOW)).not.toHaveProperty(
"trust",
);
});
it("projects recorded errors only for terminal error states", () => {
expect(
summarizeWorkerEnvironment(
@@ -324,6 +346,9 @@ describe("environment gateway methods", () => {
type: "node",
label: "Live Node",
status: "available",
platform: "ios",
sessionHost: false,
trust: "persistent",
capabilities: ["camera", "system.run"],
});
});
@@ -341,6 +366,7 @@ describe("environment gateway methods", () => {
expect(payload).toMatchObject({
id: "worker-1",
status: "available",
trust: "disposable",
worker: { state: "attached", ageMs: 9_000 },
});
expect(get).toHaveBeenCalledWith("worker-1");
@@ -25,6 +25,9 @@ const GATEWAY_ENVIRONMENT: EnvironmentSummary = {
type: "local",
label: "Gateway local",
status: "available",
platform: process.platform,
sessionHost: true,
trust: "persistent",
capabilities: ["agent.run", "sessions", "tools", "workspace"],
};
const WORKER_STATUS: Record<WorkerEnvironmentState, EnvironmentSummary["status"]> = {
@@ -54,11 +57,15 @@ function summarizeNodeEnvironment(node: NodeListNode): EnvironmentSummary {
// Expose both declared capabilities and command names so older node
// runtimes still advertise useful execution surfaces in one stable list.
const capabilities = uniqueSortedStrings(node.caps, node.commands);
const platform = node.platform?.trim();
return {
id: `node:${node.nodeId}`,
type: "node",
label: node.displayName ?? node.nodeId,
status: node.connected ? "available" : "unavailable",
...(platform ? { platform } : {}),
sessionHost: false,
trust: "persistent",
...(capabilities.length > 0 ? { capabilities } : {}),
};
}
@@ -71,6 +78,9 @@ export function summarizeWorkerEnvironment(
id: record.environmentId,
type: "worker",
status: WORKER_STATUS[record.state],
...(record.sharedHost === null
? {}
: { trust: record.sharedHost ? "persistent" : "disposable" }),
worker: {
providerId: record.providerId,
...(record.leaseId ? { leaseId: record.leaseId } : {}),
@@ -24,6 +24,7 @@ export type WorkerEnvironmentServiceRecord = {
environmentId: string;
providerId: string;
leaseId: string | null;
sharedHost: boolean | null;
state: WorkerEnvironmentState;
ownerEpoch: number;
createdAtMs: number;
+2 -4
View File
@@ -43,7 +43,7 @@ export class SsrFBlockedError extends Error {
}
}
export type LookupFn = typeof dnsLookup;
export type LookupFn = (hostname: string, options: { all: true }) => Promise<LookupAddress[]>;
export type SsrFPolicy = {
allowPrivateNetwork?: boolean;
@@ -607,9 +607,7 @@ export async function resolvePinnedHostnameWithPolicy(
);
const lookupFn = params.lookupFn ?? dnsLookup;
const results = normalizeLookupResults(
(await lookupFn(normalized, { all: true })) as LookupResult,
);
const results = normalizeLookupResults(await lookupFn(normalized, { all: true }));
if (results.length === 0) {
throw new Error(`Unable to resolve hostname: ${hostname}`);
}
@@ -0,0 +1,135 @@
import { expect, it } from "vitest";
import {
WORKSPACE,
captureUiProof,
captureUiProofEnabled,
createNewSessionPageE2eSuite,
installMockGateway,
pollLocatorText,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("hides the destination axis 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, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"node.list": { nodes: [] },
"environments.list": { environments: [], profiles: [] },
},
});
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");
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();
} finally {
await context.close();
}
});
it("refreshes destinations from gateway events while the picker stays open", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
...(captureUiProofEnabled
? {
recordVideo: {
dir: ".artifacts/control-ui-e2e/picker-liveness",
size: { height: 900, width: 1280 },
},
viewport: { height: 900, width: 1280 },
}
: {}),
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"node.list": {
nodes: [
{
nodeId: "existing-mac",
displayName: "Existing Mac",
connected: true,
commands: ["system.run"],
},
],
},
"environments.list": { environments: [], profiles: [] },
},
});
try {
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");
await trigger.click();
await place.getByRole("button", { name: "Existing Mac" }).waitFor();
const nodeRequests = (await gateway.getRequests("node.list")).length;
const environmentRequests = (await gateway.getRequests("environments.list")).length;
await gateway.setMethodResponse("node.list", {
nodes: [
{
nodeId: "existing-mac",
displayName: "Existing Mac",
connected: true,
commands: ["system.run"],
},
{
nodeId: "new-mac",
displayName: "New Mac",
connected: true,
commands: ["system.run"],
},
],
});
await gateway.emitGatewayEvent("presence", {
presence: [
{ deviceId: "existing-mac", mode: "node", reason: "connect", ts: 1 },
{ deviceId: "new-mac", mode: "node", reason: "connect", ts: 2 },
],
});
await expect
.poll(async () => (await gateway.getRequests("node.list")).length)
.toBeGreaterThan(nodeRequests);
await place.getByRole("button", { name: "New Mac" }).waitFor();
await place.getByText("This gateway", { exact: true }).waitFor();
await place.getByText("Your devices", { exact: true }).waitFor();
expect(await place.getAttribute("open")).not.toBeNull();
expect(await gateway.getRequests("environments.list")).toHaveLength(environmentRequests);
await gateway.setMethodResponse("environments.list", {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }],
});
await gateway.emitGatewayEvent("config.changed", {
path: "/tmp/openclaw.json",
hash: "picker-cloud-refresh",
ts: 3,
});
await expect
.poll(async () => (await gateway.getRequests("environments.list")).length)
.toBeGreaterThan(environmentRequests);
await place.getByText("Cloud", { exact: true }).waitFor();
await place.getByRole("button", { name: "Cloud · aws" }).waitFor();
expect(await place.getAttribute("open")).not.toBeNull();
await captureUiProof(page, "picker-after-live-regroup.png");
} finally {
await context.close();
}
});
});
+3 -29
View File
@@ -522,32 +522,6 @@ suite.define(() => {
}
});
it("hides the destination axis 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, {
workspace: WORKSPACE,
workspaceGit: true,
methodResponses: {
"node.list": { nodes: [] },
"environments.list": { environments: [], profiles: [] },
},
});
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");
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();
} finally {
await context.close();
}
});
it("uses advertised system info for Gateway place labels", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
@@ -925,7 +899,7 @@ suite.define(() => {
const placeLabel = placeTrigger.locator(".new-session-page__trigger-label");
const browserEntries = page.locator(".new-session-page__browser-list");
// Pick the node from Places.
// Pick the node from Your devices.
await placeTrigger.click();
await placeSelect.getByRole("button", { name: "MacBook" }).click();
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
@@ -950,9 +924,9 @@ suite.define(() => {
placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
)
.toBe(true);
await placeSelect.getByText("Places", { exact: true }).waitFor();
await placeSelect.getByText("Your devices", { exact: true }).waitFor();
// Destination selection stays in Places; browsing is fixed to the current target.
// 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();
@@ -249,7 +249,7 @@ suite.define(() => {
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 place.getByText("Devices", { exact: true }).waitFor();
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");
} finally {
@@ -368,7 +368,7 @@ suite.define(() => {
.locator(".new-session-page__menu-title")
.getByText("This gateway", { exact: true })
.waitFor();
await place.getByText("Devices", { exact: true }).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"]');
const phone = page.locator('[data-value="node:33333333cccccccc"]');
@@ -713,7 +713,7 @@ suite.define(() => {
.locator(".new-session-page__menu-title")
.getByText("This gateway", { exact: true })
.waitFor();
await placeSelect.getByText("Devices", { 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.
+1 -1
View File
@@ -717,7 +717,7 @@ export const en: TranslationMap = {
cloudSyncsFolder: "Syncs {folder} to the cloud worker",
folder: "Folder",
folderPlaceholder: "Agent workspace",
places: "Places",
yourDevices: "Your devices",
projects: "Projects",
projectsAdminHint: "Admins can register projects from Browse folders",
projectSearchPlaceholder: "Search projects or paste a Git URL",
+7 -4
View File
@@ -38,17 +38,20 @@ describe("readDraftCloudProfiles", () => {
{
id: " zeta ",
providerId: " static-ssh ",
trust: "disposable",
settings: { token: "hidden" },
},
{ id: "aws", providerId: "crabbox" },
{ id: "aws", providerId: "crabbox", trust: "persistent" },
{ id: "legacy", providerId: "static-ssh" },
{ id: "invalid-trust", providerId: "crabbox", trust: "temporary" },
{ id: "", providerId: "crabbox" },
{ id: "missing-provider" },
]),
).toEqual([
{ id: "aws", providerId: "crabbox" },
{ id: "legacy", providerId: "static-ssh" },
{ id: "zeta", providerId: "static-ssh" },
{ id: "aws", providerId: "crabbox", trust: "persistent" },
{ id: "invalid-trust", providerId: "crabbox", trust: undefined },
{ id: "legacy", providerId: "static-ssh", trust: undefined },
{ id: "zeta", providerId: "static-ssh", trust: "disposable" },
]);
});
});
+7 -5
View File
@@ -30,6 +30,7 @@ export type DraftNode = {
export type DraftCloudProfile = {
id: string;
providerId: string;
trust?: "persistent" | "disposable";
};
export type DraftEnvironment = {
@@ -92,16 +93,17 @@ export function readDraftCloudProfiles(value: unknown): DraftCloudProfile[] {
if (!raw || typeof raw !== "object") {
return [];
}
const profile = raw as {
id?: unknown;
providerId?: unknown;
};
const profile = raw as { id?: unknown; providerId?: unknown; trust?: unknown };
const id = normalizeOptionalString(profile.id);
const providerId = normalizeOptionalString(profile.providerId);
if (!id || !providerId) {
return [];
}
return [{ id, providerId }];
const trust: DraftCloudProfile["trust"] =
profile.trust === "persistent" || profile.trust === "disposable"
? profile.trust
: undefined;
return [{ id, providerId, trust }];
})
.toSorted((left, right) => left.id.localeCompare(right.id));
}
@@ -188,6 +188,10 @@ export class DraftGatewayState {
return this.preferenceModeValue === "loading";
}
refreshCloudProfiles() {
return this.cloudProfileTask.run();
}
synchronize(gateway: ApplicationContext["gateway"]) {
const snapshot = gateway.snapshot;
const connected = snapshot.phase === "connected";
@@ -151,6 +151,10 @@ export class DraftPlaceState {
);
}
refreshNodes() {
return this.loadNodes({ quiet: true });
}
isAdmin(): boolean {
return hasOperatorAdminAccess(this.read().context?.gateway.snapshot.hello?.auth ?? null);
}
@@ -592,9 +596,11 @@ export class DraftPlaceState {
});
}
private async loadNodes() {
private async loadNodes(options: { quiet?: boolean } = {}) {
const requestId = ++this.nodesRequestToken;
this.nodesHydrated = false;
if (!options.quiet) {
this.nodesHydrated = false;
}
const snapshot = this.read().context?.gateway.snapshot;
const client = snapshot?.client;
if (snapshot?.phase !== "connected" || !client || !this.isAdmin()) {
@@ -626,7 +632,7 @@ export class DraftPlaceState {
}
this.callbacks.requestUpdate();
} catch {
if (requestId === this.nodesRequestToken) {
if (requestId === this.nodesRequestToken && !options.quiet) {
this.nodesValue = [];
this.nodesHydrated = true;
this.callbacks.requestUpdate();
@@ -1,6 +1,7 @@
import { consume } from "@lit/context";
import { html, nothing, type ReactiveController, type ReactiveControllerHost } 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";
@@ -29,6 +30,24 @@ import type { NewSessionRouteData } from "./location.ts";
import { renderPlaceSelect } from "./place-picker.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),
@@ -49,6 +68,7 @@ class NewSessionPage extends OpenClawLightDomElement {
private openedFor: string | null = null;
private openedAgentId = "";
private messageOwnerKey = "";
private presenceSignature = "";
private readonly gateway: DraftGatewayState;
private readonly browser: DraftPlaceBrowser;
private readonly place: DraftPlaceState;
@@ -147,6 +167,41 @@ class NewSessionPage extends OpenClawLightDomElement {
(gateway, notify) => gateway.subscribe(notify),
(gateway) => this.gateway.synchronize(gateway),
)
.effect(
() => this.context?.gateway,
(gateway) => {
this.presenceSignature = presenceConnectivitySignature(
readPresence(gateway.snapshot.hello?.snapshot) ?? [],
);
return gateway.subscribeEvents((event) => {
if (this.context?.gateway !== gateway) {
return;
}
if (event.event === "config.changed") {
void this.gateway.refreshCloudProfiles();
return;
}
if (
event.event === "node.pair.requested" ||
event.event === "node.pair.resolved" ||
event.event === "device.pair.requested" ||
event.event === "device.pair.resolved"
) {
void this.place.refreshNodes();
return;
}
const presence = event.event === "presence" ? readPresence(event.payload) : null;
if (!presence) {
return;
}
const signature = presenceConnectivitySignature(presence);
if (signature !== this.presenceSignature) {
this.presenceSignature = signature;
void this.place.refreshNodes();
}
});
},
)
.watch(
() => this.context?.agents,
(agents, notify) => agents.subscribe(notify),
+36 -1
View File
@@ -88,6 +88,41 @@ describe("project picker", () => {
expect(projectCloneInput(value) !== null).toBe(expected);
});
it("groups gateway, device, and cloud destinations without status copy", () => {
const container = document.createElement("div");
render(
renderPlaceSelect(
placeParams({
showDestinations: true,
worktreeAvailable: true,
execNodes: [
{
nodeId: "macbook",
displayName: "MacBook",
connected: true,
canExec: true,
canBrowse: true,
},
],
cloudProfiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }],
}),
),
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.textContent).not.toContain("persistent");
expect(container.textContent).not.toContain("disposable");
});
it("renders local matches before remote clone results and explains missing credentials", () => {
const onCloneProject = vi.fn();
const container = document.createElement("div");
@@ -258,7 +293,7 @@ describe("Where picker", () => {
const titles = [...container.querySelectorAll(".new-session-page__menu-title")].map((element) =>
element.textContent?.trim(),
);
expect(titles).toEqual(["Folder", "Projects", "Places", "This gateway", "Devices", "Cloud"]);
expect(titles).toEqual(["Folder", "Projects", "This gateway", "Your devices", "Cloud"]);
expect(container.querySelector('[data-value="node:macbook"]')).not.toBeNull();
for (const nodeId of [
"worker",
+37 -35
View File
@@ -533,7 +533,6 @@ export function renderPlaceSelect(params: {
${params.showDestinations
? html`
<div class="new-session-page__menu-title">${t("newSession.places")}</div>
<div class="new-session-page__menu-title">${t("newSession.thisGateway")}</div>
${renderSessionMenuItem(
{
@@ -547,7 +546,9 @@ export function renderPlaceSelect(params: {
)}
${deviceNodes.length > 0
? html`
<div class="new-session-page__menu-title">${t("tabs.devices")}</div>
<div class="new-session-page__menu-title">
${t("newSession.yourDevices")}
</div>
${deviceNodes.map((node, index) =>
renderSessionMenuItem(
{
@@ -567,41 +568,42 @@ export function renderPlaceSelect(params: {
`
: nothing}
${cloudProfiles.length > 0 || (params.cloudProfileId && !activeProfile)
? html`<div class="new-session-page__menu-title">
${t("newSession.cloud")}
</div>`
: nothing}
${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,
}),
? html`
<div class="new-session-page__menu-title">${t("newSession.cloud")}</div>
${renderCloudProfileMenuItems({
profiles: cloudProfiles,
selectedId: params.cloudProfileId,
submitting: params.submitting,
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),
disabled:
!params.worktreeAvailable || Boolean(params.cloudDisabledReason),
disabledReason: params.cloudDisabledReason,
onSelect: params.onSelectCloudProfile,
})}
</div>`
${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}