mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 17:41:33 -06:00
feat(ui): unify focused presentation routes (#126143)
* feat(ui): unify focused presentation routes /focus/<target> replaces unshipped standalone query links across dashboard, terminal, desktop, and native apps. Gateway-served index assets are anchored so nested documents resolve their bundles from the Control UI base path. * test(gateway): narrow emitted asset URLs Fixes check:test-types TS18048/TS2322 by dropping unmatched optional captures before comparing emitted asset URLs. * test(docs): follow centralized cloud secret guidance Fixes the stale current-main docs test after #126132 centralized GCP and Hetzner setup in docker-vm-runtime. * test(ui): retry missing locator reads The 500ms locator text read can time out while the menu label is still rendering, causing expect.poll to reject instead of using its owning 10s retry window. Treat only Playwright TimeoutError as a missing value so the outer poll retries while page-closure and arbitrary failures still surface. * test(android): capture TLS probe coroutine The TLS probe test inferred its coroutine from mutable scope children, racing unrelated child startup and teardown in CI. Capture the exact Job from inside the probe coroutine and join that owner before asserting the stale-attempt guard. * fix(gateway): preserve plugin focus routes Keep approval handling ahead of plugin dispatch, but treat focus documents as an unclaimed Control UI fallback after plugin authentication and routing. Exact and prefix plugin routes therefore retain ownership, while unclaimed reads serve the focus document and other methods return 404. * fix(ui): migrate released terminal links Preserve stable v2026.7.1 terminal query compatibility by rewriting the root/base ?view=terminal URL once to the canonical /focus/terminal path with history.replace. Keep URL parsing path-only, and leave the removed desktop and dashboard query forms as a hard cut. * test(codex): assign run-attempt tools shard Cached filtered configs caused duplicate ownership, and the test lacked a canonical full-suite owner. * test(ui): keep cloud recovery proof state-owned The recovery test should assert owner state and reload identity, while dedicated tests own transient alert visibility. * test(qa): wait for outbound bus state * fix(qa): reserve gateway ports through staging * refactor(qa): keep socket creation in gateway owner
This commit is contained in:
committed by
GitHub
parent
9814b14c90
commit
4af09d4961
@@ -103,22 +103,26 @@ internal fun DesktopScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the desktop document route; credentials stay in ControlUiWebView's startup script. */
|
||||
/** Builds the desktop focus route; credentials stay in ControlUiWebView's startup script. */
|
||||
internal fun desktopUrl(
|
||||
baseUrl: String,
|
||||
source: String? = null,
|
||||
session: String? = null,
|
||||
): String {
|
||||
val baseUri = baseUrl.trimEnd('/').toUri()
|
||||
val routePath = "${baseUri.encodedPath.orEmpty().trimEnd('/')}/"
|
||||
val normalizedSource = source?.trim()?.takeIf(String::isNotEmpty)
|
||||
val normalizedSession = session?.trim()?.takeIf(String::isNotEmpty)
|
||||
val builder =
|
||||
baseUri
|
||||
baseUrl
|
||||
.trimEnd('/')
|
||||
.toUri()
|
||||
.buildUpon()
|
||||
.encodedPath(routePath)
|
||||
.clearQuery()
|
||||
.fragment(null)
|
||||
.appendQueryParameter("view", "desktop")
|
||||
source?.let { builder.appendQueryParameter("source", it) }
|
||||
session?.let { builder.appendQueryParameter("session", it) }
|
||||
.appendPath("focus")
|
||||
.appendPath("desktop")
|
||||
when {
|
||||
normalizedSource != null -> builder.appendPath("source").appendPath(normalizedSource)
|
||||
normalizedSession != null -> builder.appendPath("session").appendPath(normalizedSession)
|
||||
}
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
@@ -27,10 +27,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* Full-height terminal surface: embeds the gateway-served terminal-only
|
||||
* Control UI document (`/?view=terminal`, the same ghostty-web surface the
|
||||
* Control UI focus document (`/focus/terminal`, the same ghostty-web surface the
|
||||
* desktop Control UI uses) for the currently connected gateway.
|
||||
*/
|
||||
@Composable
|
||||
@@ -61,7 +62,7 @@ internal fun TerminalSettingsScreen(
|
||||
key(page) {
|
||||
ControlUiWebView(
|
||||
page = page,
|
||||
url = "${page.baseUrl}/?view=terminal",
|
||||
url = terminalUrl(page.baseUrl),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -75,3 +76,16 @@ internal fun TerminalSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the terminal focus route without putting gateway credentials in the URL. */
|
||||
internal fun terminalUrl(baseUrl: String): String =
|
||||
baseUrl
|
||||
.trimEnd('/')
|
||||
.toUri()
|
||||
.buildUpon()
|
||||
.clearQuery()
|
||||
.fragment(null)
|
||||
.appendPath("focus")
|
||||
.appendPath("terminal")
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
@@ -30,6 +30,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -438,37 +439,26 @@ class GatewayBootstrapAuthTest {
|
||||
fun connect_ignoresStaleTlsProbeAfterDisconnect() =
|
||||
runBlocking {
|
||||
val fingerprint = "aa".repeat(32)
|
||||
val probeStarted = CompletableDeferred<Unit>()
|
||||
val probeJob = CompletableDeferred<Job>()
|
||||
val probeResult = CompletableDeferred<GatewayTlsProbeResult>()
|
||||
val (_, prefs, runtime) =
|
||||
gatewayFixture { _, _ ->
|
||||
probeStarted.complete(Unit)
|
||||
probeJob.complete(checkNotNull(currentCoroutineContext()[Job]))
|
||||
probeResult.await()
|
||||
}
|
||||
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
|
||||
prefs.saveGatewayTlsFingerprint(endpoint.stableId, fingerprint)
|
||||
val runtimeScope = readField<CoroutineScope>(runtime, "scope")
|
||||
val existingJobs =
|
||||
runtimeScope.coroutineContext[Job]
|
||||
?.children
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
|
||||
runtime.connect(
|
||||
endpoint,
|
||||
auth(token = "shared-token"),
|
||||
)
|
||||
probeStarted.await()
|
||||
val probeJob =
|
||||
runtimeScope.coroutineContext[Job]
|
||||
?.children
|
||||
?.singleOrNull { it !in existingJobs }
|
||||
?: error("Expected one TLS probe job")
|
||||
val tlsProbeJob = probeJob.await()
|
||||
|
||||
runtime.disconnect()
|
||||
probeResult.complete(GatewayTlsProbeResult(fingerprintSha256 = fingerprint))
|
||||
// Join the owning coroutine so assertions run after its stale-attempt guard.
|
||||
probeJob.join()
|
||||
tlsProbeJob.join()
|
||||
|
||||
assertNull(runtime.pendingGatewayTrust.value)
|
||||
assertNull(desiredBootstrapToken(runtime, "nodeSession"))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -11,56 +10,70 @@ import org.robolectric.annotation.Config
|
||||
@Config(sdk = [34])
|
||||
class DesktopScreenTest {
|
||||
@Test
|
||||
fun desktopUrlUsesDocumentModeWithoutSource() {
|
||||
val url = desktopUrl(baseUrl = "https://gateway.example.com:8443/openclaw/")
|
||||
|
||||
assertEquals("https://gateway.example.com:8443/openclaw/?view=desktop", url)
|
||||
assertFalse(url.contains("token="))
|
||||
assertFalse(url.contains("password="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopUrlEncodesProvidedSource() {
|
||||
val url =
|
||||
desktopUrl(
|
||||
baseUrl = "https://gateway.example.com:8443",
|
||||
source = "environment:Mac Studio/QA & demo",
|
||||
fun desktopUrlBuildsCanonicalFocusPaths() {
|
||||
val cases =
|
||||
listOf(
|
||||
DesktopUrlCase(
|
||||
name = "root base",
|
||||
baseUrl = "https://gateway.example.com:8443",
|
||||
expected = "https://gateway.example.com:8443/focus/desktop",
|
||||
),
|
||||
DesktopUrlCase(
|
||||
name = "configured base path",
|
||||
baseUrl = "https://gateway.example.com:8443/openclaw/",
|
||||
expected = "https://gateway.example.com:8443/openclaw/focus/desktop",
|
||||
),
|
||||
DesktopUrlCase(
|
||||
name = "encoded source",
|
||||
baseUrl = "https://gateway.example.com:8443",
|
||||
source = "environment:Mac Studio/QA & demo",
|
||||
expected =
|
||||
"https://gateway.example.com:8443/focus/desktop/source/environment%3AMac%20Studio%2FQA%20%26%20demo",
|
||||
),
|
||||
DesktopUrlCase(
|
||||
name = "encoded session under configured base path",
|
||||
baseUrl = "https://gateway.example.com:8443/openclaw/",
|
||||
session = "agent:main:mobile session",
|
||||
expected =
|
||||
"https://gateway.example.com:8443/openclaw/focus/desktop/session/agent%3Amain%3Amobile%20session",
|
||||
),
|
||||
DesktopUrlCase(
|
||||
name = "source wins over session",
|
||||
baseUrl = "https://gateway.example.com:8443",
|
||||
source = "node:worker-1",
|
||||
session = "agent:main:mobile",
|
||||
expected = "https://gateway.example.com:8443/focus/desktop/source/node%3Aworker-1",
|
||||
),
|
||||
DesktopUrlCase(
|
||||
name = "empty source falls through to session",
|
||||
baseUrl = "https://gateway.example.com:8443",
|
||||
source = " ",
|
||||
session = "agent:main:mobile",
|
||||
expected = "https://gateway.example.com:8443/focus/desktop/session/agent%3Amain%3Amobile",
|
||||
),
|
||||
DesktopUrlCase(
|
||||
name = "empty values are omitted",
|
||||
baseUrl = "https://gateway.example.com:8443/openclaw/",
|
||||
source = " ",
|
||||
session = "\n",
|
||||
expected = "https://gateway.example.com:8443/openclaw/focus/desktop",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://gateway.example.com:8443/?view=desktop&source=environment%3AMac%20Studio%2FQA%20%26%20demo",
|
||||
url,
|
||||
)
|
||||
assertFalse(url.contains("token="))
|
||||
assertFalse(url.contains("password="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopUrlEncodesProvidedSession() {
|
||||
val url =
|
||||
desktopUrl(
|
||||
baseUrl = "https://gateway.example.com:8443/openclaw/",
|
||||
session = "agent:main:mobile session",
|
||||
cases.forEach { case ->
|
||||
assertEquals(
|
||||
case.name,
|
||||
case.expected,
|
||||
desktopUrl(baseUrl = case.baseUrl, source = case.source, session = case.session),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://gateway.example.com:8443/openclaw/?view=desktop&session=agent%3Amain%3Amobile%20session",
|
||||
url,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopUrlRetainsExplicitSourceAlongsideSession() {
|
||||
val url =
|
||||
desktopUrl(
|
||||
baseUrl = "https://gateway.example.com:8443",
|
||||
source = "node:worker-1",
|
||||
session = "agent:main:mobile",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://gateway.example.com:8443/?view=desktop&source=node%3Aworker-1&session=agent%3Amain%3Amobile",
|
||||
url,
|
||||
)
|
||||
}
|
||||
private data class DesktopUrlCase(
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val source: String? = null,
|
||||
val session: String? = null,
|
||||
val expected: String,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class TerminalSettingsScreenTest {
|
||||
@Test
|
||||
fun terminalUrlBuildsCanonicalFocusPath() {
|
||||
val cases =
|
||||
listOf(
|
||||
"https://gateway.example.com:8443" to
|
||||
"https://gateway.example.com:8443/focus/terminal",
|
||||
"https://gateway.example.com:8443/openclaw/" to
|
||||
"https://gateway.example.com:8443/openclaw/focus/terminal",
|
||||
)
|
||||
|
||||
cases.forEach { (baseUrl, expected) ->
|
||||
assertEquals(baseUrl, expected, terminalUrl(baseUrl))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,17 +100,11 @@ struct DesktopHubScreen: View {
|
||||
source: String?,
|
||||
session: String? = nil) -> URL?
|
||||
{
|
||||
var queryItems = [URLQueryItem(name: "view", value: "desktop")]
|
||||
if let source = self.normalizedSource(source) {
|
||||
queryItems.append(URLQueryItem(name: "source", value: source))
|
||||
}
|
||||
if let session = self.normalizedSource(session) {
|
||||
queryItems.append(URLQueryItem(name: "session", value: session))
|
||||
}
|
||||
guard let path = self.desktopPath(source: source, session: session) else { return nil }
|
||||
return AuthenticatedControlUI.pageURL(
|
||||
config: config,
|
||||
path: "/",
|
||||
queryItems: queryItems)
|
||||
path: path,
|
||||
queryItems: [])
|
||||
}
|
||||
|
||||
static func desktopAuthUserScript(
|
||||
@@ -147,13 +141,25 @@ struct DesktopHubScreen: View {
|
||||
hasher.combine(AuthenticatedControlUI.webContentIdentity(
|
||||
config: config,
|
||||
storedOperatorToken: storedOperatorToken))
|
||||
hasher.combine(self.normalizedSource(source))
|
||||
hasher.combine(self.normalizedSource(session))
|
||||
hasher.combine(self.desktopPath(source: source, session: session))
|
||||
return hasher.finalize()
|
||||
}
|
||||
|
||||
private static func normalizedSource(_ source: String?) -> String? {
|
||||
let trimmed = source?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
private static func desktopPath(source: String?, session: String?) -> String? {
|
||||
let basePath = "/focus/desktop"
|
||||
if let source = self.normalizedValue(source) {
|
||||
guard let encoded = AuthenticatedControlUI.percentEncodedPathSegment(source) else { return nil }
|
||||
return "\(basePath)/source/\(encoded)"
|
||||
}
|
||||
if let session = self.normalizedValue(session) {
|
||||
guard let encoded = AuthenticatedControlUI.percentEncodedPathSegment(session) else { return nil }
|
||||
return "\(basePath)/session/\(encoded)"
|
||||
}
|
||||
return basePath
|
||||
}
|
||||
|
||||
private static func normalizedValue(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
/// Control-hub Terminal destination: embeds the gateway-served terminal page
|
||||
/// (`/?view=terminal`, the ghostty-web surface shared with the Control UI) in a
|
||||
/// (`/focus/terminal`, the ghostty-web surface shared with the Control UI) in a
|
||||
/// WKWebView, authenticated with the stored gateway credentials.
|
||||
struct TerminalHubScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@@ -87,14 +87,14 @@ struct TerminalHubScreen: View {
|
||||
}
|
||||
|
||||
/// Derives the terminal page URL from the active gateway connection: the
|
||||
/// WS endpoint flips to HTTP(S) and only `view=terminal` rides in the URL.
|
||||
/// WS endpoint flips to HTTP(S) and the configured Control UI base path is preserved.
|
||||
/// Credentials never enter the URL — they are injected as a document-start
|
||||
/// user script (see `terminalAuthUserScript`), matching the macOS Dashboard.
|
||||
static func terminalURL(config: GatewayConnectConfig?) -> URL? {
|
||||
AuthenticatedControlUI.pageURL(
|
||||
config: config,
|
||||
path: "/",
|
||||
queryItems: [URLQueryItem(name: "view", value: "terminal")])
|
||||
path: "/focus/terminal",
|
||||
queryItems: [])
|
||||
}
|
||||
|
||||
/// Origin-gated document-start script that hands the gateway credentials to
|
||||
|
||||
@@ -7,6 +7,8 @@ import WebKit
|
||||
enum AuthenticatedControlUI {
|
||||
private static let queryComponentAllowed = CharacterSet(
|
||||
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
||||
private static let pathSegmentAllowed = CharacterSet(
|
||||
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!'()*")
|
||||
|
||||
static func pageURL(
|
||||
config: GatewayConnectConfig?,
|
||||
@@ -33,10 +35,16 @@ enum AuthenticatedControlUI {
|
||||
return "\(name)=\(encodedValue)"
|
||||
}
|
||||
guard encodedItems.count == queryItems.count else { return nil }
|
||||
components.percentEncodedQuery = encodedItems.joined(separator: "&")
|
||||
components.percentEncodedQuery = encodedItems.isEmpty
|
||||
? nil
|
||||
: encodedItems.joined(separator: "&")
|
||||
return components.url
|
||||
}
|
||||
|
||||
static func percentEncodedPathSegment(_ value: String) -> String? {
|
||||
value.addingPercentEncoding(withAllowedCharacters: self.pathSegmentAllowed)
|
||||
}
|
||||
|
||||
/// Origin-gated document-start script for the Control UI native-auth contract.
|
||||
static func authUserScript(
|
||||
config: GatewayConnectConfig?,
|
||||
|
||||
@@ -30,13 +30,13 @@ struct DesktopHubScreenTests {
|
||||
|
||||
@Test func `standalone desktop URL uses document mode without credentials`() throws {
|
||||
let config = try Self.makeConfig(
|
||||
url: #require(URL(string: "wss://gateway.example.com:8443/openclaw")),
|
||||
url: #require(URL(string: "wss://gateway.example.com:8443/openclaw/")),
|
||||
token: "secret-token",
|
||||
password: "secret-password")
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: nil, session: nil)
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/?view=desktop")
|
||||
#expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/focus/desktop")
|
||||
#expect(url?.absoluteString.contains("secret-token") == false)
|
||||
#expect(url?.absoluteString.contains("secret-password") == false)
|
||||
}
|
||||
@@ -49,29 +49,33 @@ struct DesktopHubScreenTests {
|
||||
let url = DesktopHubScreen.desktopURL(
|
||||
config: config,
|
||||
source: nil,
|
||||
session: "agent:main:mobile session")
|
||||
session: "agent:main/mobile session")
|
||||
|
||||
#expect(url?.absoluteString == "http://192.168.1.10:18789/?view=desktop&session=agent%3Amain%3Amobile%20session")
|
||||
#expect(
|
||||
url?.absoluteString ==
|
||||
"http://192.168.1.10:18789/focus/desktop/session/agent%3Amain%2Fmobile%20session")
|
||||
#expect(url?.absoluteString.contains("secret-token") == false)
|
||||
}
|
||||
|
||||
@Test func `explicit desktop source is retained alongside the session`() throws {
|
||||
@Test func `explicit desktop source wins over the session`() throws {
|
||||
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")))
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(
|
||||
config: config,
|
||||
source: "node:worker-1",
|
||||
source: "node:worker-1/primary?mode=qa",
|
||||
session: "agent:main:mobile")
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com/?view=desktop&source=node%3Aworker-1&session=agent%3Amain%3Amobile")
|
||||
#expect(
|
||||
url?.absoluteString ==
|
||||
"https://gateway.example.com/focus/desktop/source/node%3Aworker-1%2Fprimary%3Fmode%3Dqa")
|
||||
}
|
||||
|
||||
@Test func `empty desktop source is omitted`() throws {
|
||||
@Test func `empty desktop source and session are normalized away`() throws {
|
||||
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")))
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: " ", session: " ")
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com/?view=desktop")
|
||||
#expect(url?.absoluteString == "https://gateway.example.com/focus/desktop")
|
||||
}
|
||||
|
||||
@Test func `desktop auth script carries credentials outside the URL`() throws {
|
||||
@@ -83,7 +87,7 @@ struct DesktopHubScreenTests {
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: "gateway")
|
||||
let script = DesktopHubScreen.desktopAuthUserScript(config: config, source: "gateway")
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com/?view=desktop&source=gateway")
|
||||
#expect(url?.absoluteString == "https://gateway.example.com/focus/desktop/source/gateway")
|
||||
#expect(url?.absoluteString.contains("secret-token") == false)
|
||||
#expect(url?.absoluteString.contains("secret-password") == false)
|
||||
#expect(script?.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") == true)
|
||||
|
||||
@@ -37,12 +37,12 @@ struct TerminalHubScreenTests {
|
||||
|
||||
@Test func `terminal URL flips scheme and preserves the Control UI base path`() throws {
|
||||
let config = try Self.makeConfig(
|
||||
url: #require(URL(string: "wss://gateway.example.com:8443/openclaw")),
|
||||
url: #require(URL(string: "wss://gateway.example.com:8443/openclaw/")),
|
||||
token: "secret-token")
|
||||
|
||||
let url = TerminalHubScreen.terminalURL(config: config)
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/?view=terminal")
|
||||
#expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/focus/terminal")
|
||||
// Credentials must never ride in the page URL; they travel via the
|
||||
// document-start auth user script instead.
|
||||
#expect(url?.absoluteString.contains("secret-token") == false)
|
||||
@@ -53,7 +53,7 @@ struct TerminalHubScreenTests {
|
||||
|
||||
let url = TerminalHubScreen.terminalURL(config: config)
|
||||
|
||||
#expect(url?.absoluteString == "http://192.168.1.10:18789/?view=terminal")
|
||||
#expect(url?.absoluteString == "http://192.168.1.10:18789/focus/terminal")
|
||||
}
|
||||
|
||||
@Test func `auth user script carries credentials gated to the page origin`() throws {
|
||||
|
||||
@@ -441,14 +441,14 @@ Codex and Claude Code sessions discovered in the sessions sidebar can open in th
|
||||
|
||||
Eligibility is per session and per host. Gateway-local sessions start the provider-owned resume command on the Gateway host. Paired-node sessions start an allowlisted provider command on the owning node and relay only that PTY's output, input, and resize events; this does not expose a general node shell or accept browser-supplied commands. File uploads use the separate, size-bounded `terminal.upload` node command and remain bound to the already-open terminal session. Approve the node pairing upgrade when that command first appears. Nodes that do not advertise the matching terminal-resume command, including embedded worker bridges without duplex streaming, keep the viewer available and show terminal opening as unavailable; older nodes can still run a terminal but cannot receive dragged files.
|
||||
|
||||
Standalone operator sessions, including the full-screen terminal document, are connection-owned. A page reload, laptop sleep, or network blip detaches one on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached connection-owned sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). Attaching one of these sessions remains tmux-style take-over.
|
||||
Standalone operator sessions, including the terminal focus presentation, are connection-owned. A page reload, laptop sleep, or network blip detaches one on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached connection-owned sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). Attaching one of these sessions remains tmux-style take-over.
|
||||
|
||||
Conversation-owned sessions, whether opened by the agent tool or from that Chat session's Terminal panel, are not bound to a browser connection. `terminal.attach` adds each browser as a viewer without taking ownership, and closing a viewer tab detaches only that browser. Conversation-owned PTYs remain until the agent closes them, their process exits, policy disables them, or the Gateway shuts down. PTYs opened by a detached task close automatically when that task succeeds, fails, times out, is cancelled, or is lost. `terminal.list` marks each entry as connection- or agent-owned.
|
||||
|
||||
All Gateway terminal PTYs are process-local. A Gateway restart ends them; the
|
||||
PTY sessions and their scrollback are not recovered after the new process starts.
|
||||
|
||||
The terminal is also available as a [full-screen terminal document](/web/urls#special-documents-and-startup-modes). The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal.
|
||||
The terminal is also available as a [focus presentation](/web/urls#focus-presentation-routes). The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal. Focus presentation removes the application chrome; it does not invoke browser fullscreen.
|
||||
|
||||
## Browser panel
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@ thread's `/dashboard/<agent>/<sessionRef>` URL. An open Dashboards page updates
|
||||
as threads are renamed, archived, deleted, or switched between Chat and
|
||||
Dashboard, including after a Gateway reconnect.
|
||||
|
||||
Use **Open full-screen dashboard** on a row to open its board as a standalone
|
||||
browser document with no sidebar, top bar, or chat. The close button returns to
|
||||
the previous page. Inside a session, use the fullscreen button beside the
|
||||
Chat / Split / Dashboard switch to enter or leave browser fullscreen while the
|
||||
board is visible.
|
||||
Use **Open dashboard in focus mode** on a row to open its board as a standalone
|
||||
browser document at `/focus/dashboard/<agent>/<sessionRef>`, with no sidebar,
|
||||
top bar, or chat. This focus presentation does not invoke browser fullscreen;
|
||||
the close button returns to the previous page. Inside a session, use the
|
||||
fullscreen button beside the Chat / Split / Dashboard switch to enter or leave
|
||||
browser fullscreen while the board is visible.
|
||||
|
||||
The Chat or Dashboard face preference is stored server-side per thread. It
|
||||
therefore follows you when you connect to the same gateway from another device.
|
||||
|
||||
+74
-16
@@ -1,9 +1,9 @@
|
||||
---
|
||||
summary: "Control UI URL routes, stable session-link grammar, and connection handoff parameters"
|
||||
summary: "Control UI routes, focus presentations, stable session links, and connection handoff parameters"
|
||||
read_when:
|
||||
- You need to bookmark or share a Control UI session
|
||||
- You are adding or changing a Control UI route
|
||||
- You need a terminal, approval, onboarding, or remote Gateway URL
|
||||
- You need a terminal, desktop, approval, onboarding, or remote Gateway URL
|
||||
title: "Control UI URLs"
|
||||
---
|
||||
|
||||
@@ -123,10 +123,70 @@ own `?session=` parameter because that parameter expands a row; it is not a
|
||||
session deep link. The one-shot composer value `?draft=` remains supported on
|
||||
chat and dashboard session paths.
|
||||
|
||||
This canonical-link restriction applies to application routes. The standalone
|
||||
dashboard document described below intentionally uses
|
||||
`/?view=dashboard&session=<sessionKey>` because it is a special document, not a
|
||||
session route.
|
||||
## Focus presentation routes
|
||||
|
||||
A focus route renders one supported content surface without the normal Control
|
||||
UI application chrome. Focus presentation is separate from browser fullscreen:
|
||||
opening a focus route does not invoke the browser Fullscreen API.
|
||||
|
||||
Insert `/focus` immediately after the configured Control UI base path. Removing
|
||||
it returns the corresponding normal route when one exists:
|
||||
|
||||
```text
|
||||
/dashboard/roboclaw/the-daily-claw-6d7c9ccb
|
||||
/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb
|
||||
|
||||
/openclaw/dashboard/roboclaw/the-daily-claw-6d7c9ccb
|
||||
/openclaw/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb
|
||||
```
|
||||
|
||||
Dashboard focus routes use the complete canonical `/dashboard` grammar above:
|
||||
|
||||
```text
|
||||
/focus/dashboard/<agentId>
|
||||
/focus/dashboard/<agentId>/<sessionRef...>
|
||||
```
|
||||
|
||||
The Control UI removes the focus modifier before passing the dashboard route to
|
||||
the canonical session resolver. Canonical address replacement and ambiguity
|
||||
candidate links preserve `/focus`. Missing, ambiguous, and unavailable sessions
|
||||
remain visible, and the dashboard is not read until the session resolves to a
|
||||
canonical key.
|
||||
|
||||
The other focus targets are:
|
||||
|
||||
```text
|
||||
/focus/terminal
|
||||
|
||||
/focus/desktop
|
||||
/focus/desktop/source/<encodedSource>
|
||||
/focus/desktop/session/<encodedExactSessionKey>
|
||||
/focus/desktop/control
|
||||
/focus/desktop/control/source/<encodedSource>
|
||||
/focus/desktop/control/session/<encodedExactSessionKey>
|
||||
```
|
||||
|
||||
Encode desktop source and exact-session-key values with `encodeURIComponent` so
|
||||
each occupies one path segment. Empty source and session values are omitted. If
|
||||
a native caller supplies both non-empty values, the source form wins. The
|
||||
optional `control` segment requests initial control; it does not grant control
|
||||
or authorize the connection.
|
||||
|
||||
The focus target and desktop identity or options are path-only. Credentials do
|
||||
not belong in these URLs. Each target keeps the startup, authentication,
|
||||
permission, and capability checks of its normal or embedded surface. In
|
||||
particular, the terminal still requires `gateway.terminal.enabled` and an
|
||||
`operator.admin` connection.
|
||||
|
||||
Stable releases previously emitted `/?view=terminal`. The Control UI accepts
|
||||
that form only at the application root (or `<basePath>/?view=terminal`) and
|
||||
immediately replaces it in browser history with `/focus/terminal` under the
|
||||
same base path, removing the legacy `view` parameter. New links must use
|
||||
`/focus/terminal`. The query form is not recognized on other application
|
||||
paths, and the removed desktop and dashboard query forms are not accepted.
|
||||
|
||||
`/focus` and unsupported `/focus/*` targets show an error without the ordinary
|
||||
application shell. They do not open a normal application route.
|
||||
|
||||
## Route table
|
||||
|
||||
@@ -200,24 +260,22 @@ Agent selection and its `overview|files|tools|skills|channels|cron|memory`
|
||||
panels use paths. Older links with `?agent=<agentId>` are replaced once with
|
||||
the agent path while keeping other query parameters and the fragment.
|
||||
|
||||
## Special documents and startup modes
|
||||
## Other special documents and startup modes
|
||||
|
||||
These Gateway-served documents sit outside the application route table:
|
||||
|
||||
- `/?onboarding=1` opens the first-run onboarding presentation.
|
||||
- `/terminal` opens the user-facing full-screen terminal. With a base path, use
|
||||
`<basePath>/terminal`.
|
||||
- `/?view=terminal` opens the same terminal-only document in the WebView/embed
|
||||
form used by the mobile apps. Terminal availability in either form still
|
||||
requires `gateway.terminal.enabled` and `operator.admin`.
|
||||
- `/?view=dashboard&session=<sessionKey>` opens that session's interactive
|
||||
dashboard full-window without application or chat chrome. The document stays
|
||||
connected to live board updates and shows a visible empty state when the
|
||||
session or board is unavailable.
|
||||
- `/approve/<approvalId>` opens a standalone approval document. With a base
|
||||
path, use `<basePath>/approve/<approvalId>`. The id identifies an approval but
|
||||
never authorizes it; normal Gateway authentication still applies.
|
||||
|
||||
Registered exact and prefix plugin HTTP routes can own `/focus` and
|
||||
`/focus/*`. After plugin authentication and dispatch decline a request, the
|
||||
Gateway uses those paths as the Control UI focus fallback: unclaimed `GET` and
|
||||
`HEAD` requests serve the Control UI document, while other methods return
|
||||
`404`. Every unclaimed method returns `404` when Control UI serving is
|
||||
disabled. Lookalikes such as `/focused` are not part of the focus fallback.
|
||||
|
||||
The approval namespace is reserved ahead of plugin HTTP routes for all HTTP
|
||||
methods. When Control UI serving is disabled, it returns `404` instead of
|
||||
falling through to a plugin route.
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
waitForQaGatewayRestartBoundary,
|
||||
} from "./gateway-child-readiness.js";
|
||||
import { redactQaGatewayDebugText } from "./gateway-log-redaction.js";
|
||||
import { reserveQaGatewayPort } from "./gateway-port-reservation.js";
|
||||
import {
|
||||
createQaGatewayProcessBoundaryController,
|
||||
type QaGatewayVerifiedProcessIdentity,
|
||||
@@ -102,21 +103,6 @@ function createQaGatewayEmptyTransport() {
|
||||
} satisfies Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", (error) => reject(error));
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("failed to allocate port"));
|
||||
return;
|
||||
}
|
||||
server.close((error) => (error ? reject(error) : resolve(address.port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function appendQaGatewayTempRoot(details: string, tempRoot: string) {
|
||||
return details.includes(tempRoot)
|
||||
? details
|
||||
@@ -261,6 +247,7 @@ export async function startQaGatewayChild(params: {
|
||||
ReturnType<typeof createQaGatewayProcessBoundaryController>
|
||||
> | null = null;
|
||||
let rpcClient: Awaited<ReturnType<typeof startQaGatewayRpcClient>> | null = null;
|
||||
let gatewayPortReservation: Awaited<ReturnType<typeof reserveQaGatewayPort>> | null = null;
|
||||
let stagedBundledPluginsRoot: string | null = null;
|
||||
const tempRoot = await fs.mkdtemp(path.join(tempParentDir, "openclaw-qa-suite-"));
|
||||
// The startup owner must release its temp root even when launcher or staging
|
||||
@@ -514,7 +501,8 @@ export async function startQaGatewayChild(params: {
|
||||
};
|
||||
for (let attempt = 1; attempt <= QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS; attempt += 1) {
|
||||
if (!reuseStartupLaunchState) {
|
||||
gatewayPort = await getFreePort();
|
||||
gatewayPortReservation = await reserveQaGatewayPort(() => net.createServer());
|
||||
gatewayPort = gatewayPortReservation.port;
|
||||
baseUrl = `http://127.0.0.1:${gatewayPort}`;
|
||||
wsUrl = `ws://127.0.0.1:${gatewayPort}`;
|
||||
cfg = await buildStagedGatewayConfig(gatewayPort);
|
||||
@@ -603,6 +591,10 @@ export async function startQaGatewayChild(params: {
|
||||
reuseStartupLaunchState = false;
|
||||
|
||||
const attemptLogMark = output.mark();
|
||||
// Hold the selected port through plugin/config staging so parallel QA workers
|
||||
// cannot satisfy readiness against one another. Release only for the child bind.
|
||||
await gatewayPortReservation?.release();
|
||||
gatewayPortReservation = null;
|
||||
const spawnedAttempt = await spawnGatewayProcess(env);
|
||||
const attemptChild = spawnedAttempt.child;
|
||||
child = attemptChild;
|
||||
@@ -1027,6 +1019,13 @@ export async function startQaGatewayChild(params: {
|
||||
};
|
||||
} catch (error) {
|
||||
const cleanupErrors: unknown[] = [];
|
||||
if (gatewayPortReservation) {
|
||||
try {
|
||||
await gatewayPortReservation.release();
|
||||
} catch (cleanupError) {
|
||||
cleanupErrors.push(cleanupError);
|
||||
}
|
||||
}
|
||||
await rpcClient?.stop().catch(() => {});
|
||||
let processStopped = child === null;
|
||||
if (child) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Qa Lab tests cover Gateway port reservation behavior.
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { reserveQaGatewayPort } from "./gateway-port-reservation.js";
|
||||
|
||||
const servers = new Set<net.Server>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...servers].map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
servers.clear();
|
||||
});
|
||||
|
||||
async function bindReservedPort(port: number) {
|
||||
const server = net.createServer();
|
||||
servers.add(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", resolve);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
describe("reserveQaGatewayPort", () => {
|
||||
it("keeps the selected port unavailable until release", async () => {
|
||||
const reservation = await reserveQaGatewayPort(() => net.createServer());
|
||||
|
||||
await expect(bindReservedPort(reservation.port)).rejects.toMatchObject({ code: "EADDRINUSE" });
|
||||
await reservation.release();
|
||||
|
||||
await expect(bindReservedPort(reservation.port)).resolves.toBeInstanceOf(net.Server);
|
||||
await expect(reservation.release()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Qa Lab plugin module reserves Gateway ports across pre-spawn setup.
|
||||
type QaGatewayPortServer = {
|
||||
once(event: "error", listener: (error: Error) => void): void;
|
||||
off(event: "error", listener: (error: Error) => void): void;
|
||||
listen(port: number, host: string, listener: () => void): void;
|
||||
address(): { port: number } | string | null;
|
||||
close(callback?: (error?: Error) => void): void;
|
||||
};
|
||||
|
||||
export async function reserveQaGatewayPort(createServer: () => QaGatewayPortServer) {
|
||||
const server = createServer();
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const handleError = (error: Error) => {
|
||||
server.close(() => {});
|
||||
reject(error);
|
||||
};
|
||||
server.once("error", handleError);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", handleError);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("failed to reserve gateway port"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
let releasePromise: Promise<void> | undefined;
|
||||
return {
|
||||
port,
|
||||
release() {
|
||||
releasePromise ??= new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return releasePromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resetPluginRuntimeStateForTest,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startQaBusServer } from "./bus-server.js";
|
||||
import { createQaBusState } from "./bus-state.js";
|
||||
import { createQaRunnerRuntime } from "./harness-runtime.js";
|
||||
@@ -80,19 +80,17 @@ describe("QA runner runtime integration", () => {
|
||||
text: "ping",
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
vi.waitFor(
|
||||
() => {
|
||||
expect(harness.state.getSnapshot().messages).toContainEqual(
|
||||
expect.objectContaining({ direction: "outbound", text: "qa-echo: ping" }),
|
||||
);
|
||||
},
|
||||
{ interval: 25, timeout: 2_000 },
|
||||
),
|
||||
const outbound = await Promise.race([
|
||||
harness.state.waitFor({
|
||||
kind: "message-text",
|
||||
direction: "outbound",
|
||||
textIncludes: "qa-echo: ping",
|
||||
}),
|
||||
harness.gatewayTask.then(() => {
|
||||
throw new Error("QA Channel gateway stopped before delivering the turn");
|
||||
}),
|
||||
]);
|
||||
expect(outbound).toMatchObject({ direction: "outbound", text: "qa-echo: ping" });
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildControlUiFocusPath,
|
||||
inferControlUiFocusBasePath,
|
||||
parseControlUiFocusLocation,
|
||||
} from "./focus.js";
|
||||
|
||||
describe("Control UI focus locations", () => {
|
||||
it.each([
|
||||
["dashboard main", "/focus/dashboard/roboclaw", undefined, "/dashboard/roboclaw"],
|
||||
[
|
||||
"dashboard short reference",
|
||||
"/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb",
|
||||
undefined,
|
||||
"/dashboard/roboclaw/the-daily-claw-6d7c9ccb",
|
||||
],
|
||||
[
|
||||
"dashboard literal key",
|
||||
"/focus/dashboard/roboclaw/~key/12345678",
|
||||
undefined,
|
||||
"/dashboard/roboclaw/~key/12345678",
|
||||
],
|
||||
[
|
||||
"base-path dashboard",
|
||||
"/openclaw/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb/",
|
||||
"/openclaw",
|
||||
"/openclaw/dashboard/roboclaw/the-daily-claw-6d7c9ccb",
|
||||
],
|
||||
])("parses %s through the underlying dashboard route", (_name, pathname, basePath, routePath) => {
|
||||
expect(parseControlUiFocusLocation(pathname, basePath)).toEqual({
|
||||
status: "valid",
|
||||
basePath: basePath ?? "",
|
||||
target: {
|
||||
kind: "dashboard",
|
||||
route: { pathname: routePath, search: "", hash: "" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["terminal", "/focus/terminal", { kind: "terminal" }],
|
||||
["desktop", "/focus/desktop/", { kind: "desktop", control: false, selector: null }],
|
||||
[
|
||||
"desktop source",
|
||||
"/focus/desktop/source/environment%3AMac%20Studio%2FQA%20%26%20demo",
|
||||
{
|
||||
kind: "desktop",
|
||||
control: false,
|
||||
selector: { kind: "source", value: "environment:Mac Studio/QA & demo" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"desktop session",
|
||||
"/focus/desktop/session/agent%3Amain%3Amobile%20session",
|
||||
{
|
||||
kind: "desktop",
|
||||
control: false,
|
||||
selector: { kind: "session", value: "agent:main:mobile session" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"controlled desktop",
|
||||
"/focus/desktop/control",
|
||||
{ kind: "desktop", control: true, selector: null },
|
||||
],
|
||||
[
|
||||
"controlled source",
|
||||
"/focus/desktop/control/source/node%3Aworker-1",
|
||||
{
|
||||
kind: "desktop",
|
||||
control: true,
|
||||
selector: { kind: "source", value: "node:worker-1" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"controlled session",
|
||||
"/focus/desktop/control/session/agent%3Amain%3Amobile",
|
||||
{
|
||||
kind: "desktop",
|
||||
control: true,
|
||||
selector: { kind: "session", value: "agent:main:mobile" },
|
||||
},
|
||||
],
|
||||
] as const)("parses %s", (_name, pathname, target) => {
|
||||
expect(parseControlUiFocusLocation(pathname, "")).toEqual({
|
||||
status: "valid",
|
||||
basePath: "",
|
||||
target,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/focus",
|
||||
"/focus/unknown",
|
||||
"/focus/terminal/extra",
|
||||
"/focus/desktop/source",
|
||||
"/focus/desktop/session/%",
|
||||
"/focus/desktop/control/unknown/value",
|
||||
"/focus/dashboard",
|
||||
])("rejects malformed or unsupported target %s", (pathname) => {
|
||||
expect(parseControlUiFocusLocation(pathname, "")).toEqual({
|
||||
status: "unsupported",
|
||||
basePath: "",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/?view=dashboard&session=agent%3Amain%3Awork",
|
||||
"/?view=terminal",
|
||||
"/?view=desktop",
|
||||
"/terminal",
|
||||
"/desktop",
|
||||
"/focused/terminal",
|
||||
])("does not parse query aliases or lookalike location %s", (pathname) => {
|
||||
expect(parseControlUiFocusLocation(pathname, "")).toBeNull();
|
||||
});
|
||||
|
||||
it("infers focus-aware base paths without overriding an explicit base", () => {
|
||||
expect(inferControlUiFocusBasePath("/focus/terminal")).toBe("");
|
||||
expect(inferControlUiFocusBasePath("/openclaw/focus/desktop")).toBe("/openclaw");
|
||||
expect(inferControlUiFocusBasePath("/company/focus/focus/terminal")).toBe("/company/focus");
|
||||
expect(inferControlUiFocusBasePath("/focused/terminal")).toBeNull();
|
||||
expect(parseControlUiFocusLocation("/openclaw/focus/terminal", "/other")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes dashboard search and hash through to the canonical route loader", () => {
|
||||
expect(
|
||||
parseControlUiFocusLocation({
|
||||
pathname: "/focus/dashboard/main",
|
||||
search: "?catalog=beam&host=gateway&thread=one",
|
||||
hash: "#pane",
|
||||
}),
|
||||
).toEqual({
|
||||
status: "valid",
|
||||
basePath: "",
|
||||
target: {
|
||||
kind: "dashboard",
|
||||
route: {
|
||||
pathname: "/dashboard/main",
|
||||
search: "?catalog=beam&host=gateway&thread=one",
|
||||
hash: "#pane",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildControlUiFocusPath", () => {
|
||||
it.each([
|
||||
[
|
||||
"dashboard",
|
||||
{ kind: "dashboard", path: "/dashboard/roboclaw/the-daily-claw-6d7c9ccb" },
|
||||
"",
|
||||
"/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb",
|
||||
],
|
||||
[
|
||||
"base-path dashboard with suffix",
|
||||
{ kind: "dashboard", path: "/openclaw/dashboard/roboclaw/main?catalog=beam#pane" },
|
||||
"/openclaw/",
|
||||
"/openclaw/focus/dashboard/roboclaw/main?catalog=beam#pane",
|
||||
],
|
||||
["terminal", { kind: "terminal" }, "/openclaw", "/openclaw/focus/terminal"],
|
||||
["desktop", { kind: "desktop" }, "", "/focus/desktop"],
|
||||
[
|
||||
"desktop source",
|
||||
{ kind: "desktop", source: "environment:Mac Studio/QA & demo" },
|
||||
"",
|
||||
"/focus/desktop/source/environment%3AMac%20Studio%2FQA%20%26%20demo",
|
||||
],
|
||||
[
|
||||
"desktop session",
|
||||
{ kind: "desktop", session: "agent:main:mobile session" },
|
||||
"",
|
||||
"/focus/desktop/session/agent%3Amain%3Amobile%20session",
|
||||
],
|
||||
[
|
||||
"controlled source wins",
|
||||
{
|
||||
kind: "desktop",
|
||||
control: true,
|
||||
source: "node:worker-1",
|
||||
session: "agent:main:mobile",
|
||||
},
|
||||
"",
|
||||
"/focus/desktop/control/source/node%3Aworker-1",
|
||||
],
|
||||
[
|
||||
"controlled session",
|
||||
{ kind: "desktop", control: true, session: "agent:main:mobile" },
|
||||
"",
|
||||
"/focus/desktop/control/session/agent%3Amain%3Amobile",
|
||||
],
|
||||
[
|
||||
"empty values",
|
||||
{ kind: "desktop", source: " ", session: "" },
|
||||
"/openclaw",
|
||||
"/openclaw/focus/desktop",
|
||||
],
|
||||
] as const)("builds %s", (_name, target, basePath, expected) => {
|
||||
expect(buildControlUiFocusPath(target, basePath)).toBe(expected);
|
||||
});
|
||||
|
||||
it("rejects a dashboard route outside the configured base path", () => {
|
||||
expect(
|
||||
buildControlUiFocusPath({ kind: "dashboard", path: "/dashboard/roboclaw/main" }, "/openclaw"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { normalizeControlUiBasePath } from "./grammar.js";
|
||||
|
||||
const FOCUS_SEGMENT = "/focus";
|
||||
|
||||
type ControlUiFocusDashboardTarget = {
|
||||
kind: "dashboard";
|
||||
/** Existing canonical dashboard route, including any search or hash suffix. */
|
||||
path: string;
|
||||
};
|
||||
|
||||
type ControlUiFocusDesktopBuildTarget = {
|
||||
kind: "desktop";
|
||||
control?: boolean;
|
||||
source?: string | null;
|
||||
session?: string | null;
|
||||
};
|
||||
|
||||
export type ControlUiFocusBuildTarget =
|
||||
| ControlUiFocusDashboardTarget
|
||||
| { kind: "terminal" }
|
||||
| ControlUiFocusDesktopBuildTarget;
|
||||
|
||||
export type ControlUiFocusTarget =
|
||||
| {
|
||||
kind: "dashboard";
|
||||
route: { pathname: string; search: string; hash: string };
|
||||
}
|
||||
| { kind: "terminal" }
|
||||
| {
|
||||
kind: "desktop";
|
||||
control: boolean;
|
||||
selector: { kind: "source" | "session"; value: string } | null;
|
||||
};
|
||||
|
||||
export type ControlUiFocusLocation =
|
||||
| { status: "valid"; basePath: string; target: ControlUiFocusTarget }
|
||||
| { status: "unsupported"; basePath: string };
|
||||
|
||||
type ControlUiFocusLocationInput = string | { pathname: string; search?: string; hash?: string };
|
||||
|
||||
function normalizePathname(pathname: string): string {
|
||||
const trimmed = pathname.trim();
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
return withSlash.length > 1 ? withSlash.replace(/\/+$/u, "") : withSlash;
|
||||
}
|
||||
|
||||
function splitPathSuffix(value: string): { pathname: string; suffix: string } {
|
||||
const queryIndex = value.indexOf("?");
|
||||
const hashIndex = value.indexOf("#");
|
||||
const suffixIndex = [queryIndex, hashIndex]
|
||||
.filter((index) => index >= 0)
|
||||
.reduce((first, index) => Math.min(first, index), value.length);
|
||||
return { pathname: value.slice(0, suffixIndex), suffix: value.slice(suffixIndex) };
|
||||
}
|
||||
|
||||
function nonEmptyValue(value: string | null | undefined): string | null {
|
||||
return value && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function decodeFocusValue(segment: string): { ok: true; value: string | null } | { ok: false } {
|
||||
try {
|
||||
return { ok: true, value: nonEmptyValue(decodeURIComponent(segment)) };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function inferControlUiFocusBasePath(pathname: string): string | null {
|
||||
const normalizedPath = normalizePathname(pathname);
|
||||
const segments = normalizedPath.split("/").filter(Boolean);
|
||||
const focusIndexes = segments.flatMap((segment, index) =>
|
||||
segment === FOCUS_SEGMENT.slice(1) ? [index] : [],
|
||||
);
|
||||
if (focusIndexes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const supportsSuffix = (index: number): boolean => {
|
||||
const rest = segments.slice(index + 1);
|
||||
if (rest[0] === "terminal") {
|
||||
return rest.length === 1;
|
||||
}
|
||||
if (rest[0] === "dashboard") {
|
||||
return rest.length >= 2;
|
||||
}
|
||||
if (rest[0] !== "desktop") {
|
||||
return false;
|
||||
}
|
||||
const selectorIndex = rest[1] === "control" ? 2 : 1;
|
||||
return (
|
||||
rest.length === selectorIndex ||
|
||||
(rest.length === selectorIndex + 2 &&
|
||||
(rest[selectorIndex] === "source" || rest[selectorIndex] === "session"))
|
||||
);
|
||||
};
|
||||
let focusIndex = focusIndexes.at(-1) ?? 0;
|
||||
for (let index = focusIndexes.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = focusIndexes[index];
|
||||
if (candidate !== undefined && supportsSuffix(candidate)) {
|
||||
focusIndex = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return normalizeControlUiBasePath(segments.slice(0, focusIndex).join("/"));
|
||||
}
|
||||
|
||||
export function isControlUiFocusPath(pathname: string, basePath = ""): boolean {
|
||||
const normalizedPath = normalizePathname(pathname);
|
||||
const root = `${normalizeControlUiBasePath(basePath)}${FOCUS_SEGMENT}`;
|
||||
return normalizedPath === root || normalizedPath.startsWith(`${root}/`);
|
||||
}
|
||||
|
||||
export function buildControlUiFocusPath(
|
||||
target: Exclude<ControlUiFocusBuildTarget, ControlUiFocusDashboardTarget>,
|
||||
basePath?: string,
|
||||
): string;
|
||||
export function buildControlUiFocusPath(
|
||||
target: ControlUiFocusDashboardTarget,
|
||||
basePath?: string,
|
||||
): string | null;
|
||||
export function buildControlUiFocusPath(
|
||||
target: ControlUiFocusBuildTarget,
|
||||
basePath?: string,
|
||||
): string | null;
|
||||
export function buildControlUiFocusPath(
|
||||
target: ControlUiFocusBuildTarget,
|
||||
basePath = "",
|
||||
): string | null {
|
||||
const base = normalizeControlUiBasePath(basePath);
|
||||
const root = `${base}${FOCUS_SEGMENT}`;
|
||||
if (target.kind === "terminal") {
|
||||
return `${root}/terminal`;
|
||||
}
|
||||
if (target.kind === "desktop") {
|
||||
const control = target.control === true ? "/control" : "";
|
||||
const source = nonEmptyValue(target.source);
|
||||
const session = nonEmptyValue(target.session);
|
||||
const selector = source
|
||||
? `/source/${encodeURIComponent(source)}`
|
||||
: session
|
||||
? `/session/${encodeURIComponent(session)}`
|
||||
: "";
|
||||
return `${root}/desktop${control}${selector}`;
|
||||
}
|
||||
const { pathname, suffix } = splitPathSuffix(target.path);
|
||||
const normalizedPath = normalizePathname(pathname);
|
||||
const dashboardRoot = `${base}/dashboard/`;
|
||||
if (!normalizedPath.startsWith(dashboardRoot)) {
|
||||
return null;
|
||||
}
|
||||
return `${root}${normalizedPath.slice(base.length)}${suffix}`;
|
||||
}
|
||||
|
||||
export function parseControlUiFocusLocation(
|
||||
input: ControlUiFocusLocationInput,
|
||||
basePath?: string,
|
||||
): ControlUiFocusLocation | null {
|
||||
const pathname = typeof input === "string" ? input : input.pathname;
|
||||
const search = typeof input === "string" ? "" : (input.search ?? "");
|
||||
const hash = typeof input === "string" ? "" : (input.hash ?? "");
|
||||
const normalizedPath = normalizePathname(pathname);
|
||||
const resolvedBasePath =
|
||||
basePath === undefined
|
||||
? inferControlUiFocusBasePath(normalizedPath)
|
||||
: normalizeControlUiBasePath(basePath);
|
||||
if (resolvedBasePath === null || !isControlUiFocusPath(normalizedPath, resolvedBasePath)) {
|
||||
return null;
|
||||
}
|
||||
const root = `${resolvedBasePath}${FOCUS_SEGMENT}`;
|
||||
const rest = normalizedPath.slice(root.length + 1);
|
||||
if (rest === "terminal") {
|
||||
return { status: "valid", basePath: resolvedBasePath, target: { kind: "terminal" } };
|
||||
}
|
||||
if (rest.startsWith("dashboard/") && rest.length > "dashboard/".length) {
|
||||
return {
|
||||
status: "valid",
|
||||
basePath: resolvedBasePath,
|
||||
target: {
|
||||
kind: "dashboard",
|
||||
route: { pathname: `${resolvedBasePath}/${rest}`, search, hash },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const segments = rest.split("/");
|
||||
if (segments[0] !== "desktop") {
|
||||
return { status: "unsupported", basePath: resolvedBasePath };
|
||||
}
|
||||
let index = 1;
|
||||
const control = segments[index] === "control";
|
||||
if (control) {
|
||||
index += 1;
|
||||
}
|
||||
if (segments.length === index) {
|
||||
return {
|
||||
status: "valid",
|
||||
basePath: resolvedBasePath,
|
||||
target: { kind: "desktop", control, selector: null },
|
||||
};
|
||||
}
|
||||
const selectorKind = segments[index];
|
||||
const encodedValue = segments[index + 1];
|
||||
if (
|
||||
segments.length !== index + 2 ||
|
||||
(selectorKind !== "source" && selectorKind !== "session") ||
|
||||
encodedValue === undefined
|
||||
) {
|
||||
return { status: "unsupported", basePath: resolvedBasePath };
|
||||
}
|
||||
const decoded = decodeFocusValue(encodedValue);
|
||||
if (!decoded.ok) {
|
||||
return { status: "unsupported", basePath: resolvedBasePath };
|
||||
}
|
||||
if (!decoded.value) {
|
||||
return {
|
||||
status: "valid",
|
||||
basePath: resolvedBasePath,
|
||||
target: { kind: "desktop", control, selector: null },
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "valid",
|
||||
basePath: resolvedBasePath,
|
||||
target: {
|
||||
kind: "desktop",
|
||||
control,
|
||||
selector: { kind: selectorKind, value: decoded.value },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./grammar.js";
|
||||
|
||||
export { normalizeControlUiBasePath };
|
||||
export * from "./focus.js";
|
||||
|
||||
// Control UI session URL grammar shared by browser and plugin consumers.
|
||||
export type ControlUiSessionNamespace = "chat" | "dashboard";
|
||||
|
||||
@@ -25,7 +25,7 @@ async function readInstallDocs(): Promise<Array<{ docName: string; markdown: str
|
||||
}
|
||||
|
||||
describe("cloud install docs", () => {
|
||||
it("does not publish a copy-paste gateway token placeholder", async () => {
|
||||
it("keeps cloud install secret guidance safe and centralized", async () => {
|
||||
for (const { docName, markdown } of await readInstallDocs()) {
|
||||
for (const token of KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS) {
|
||||
expect(markdown, docName).not.toContain(`OPENCLAW_GATEWAY_TOKEN=${token}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyControlUiRequest,
|
||||
isControlUiApprovalDocumentPath,
|
||||
isControlUiFocusDocumentPath,
|
||||
isControlUiPluginManagerRequest,
|
||||
} from "./control-ui-routing.js";
|
||||
|
||||
@@ -44,6 +45,27 @@ describe("isControlUiApprovalDocumentPath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isControlUiFocusDocumentPath", () => {
|
||||
it.each([
|
||||
{ basePath: "", pathname: "/focus" },
|
||||
{ basePath: "", pathname: "/focus/" },
|
||||
{ basePath: "", pathname: "/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb" },
|
||||
{ basePath: "", pathname: "/focus/not-supported" },
|
||||
{ basePath: "/openclaw", pathname: "/openclaw/focus/desktop/control" },
|
||||
])("classifies $pathname", ({ basePath, pathname }) => {
|
||||
expect(isControlUiFocusDocumentPath({ basePath, pathname })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ basePath: "", pathname: "/focused" },
|
||||
{ basePath: "", pathname: "/focused/terminal" },
|
||||
{ basePath: "/openclaw", pathname: "/focus/terminal" },
|
||||
{ basePath: "/openclaw", pathname: "/openclaw/focused" },
|
||||
])("does not classify $pathname", ({ basePath, pathname }) => {
|
||||
expect(isControlUiFocusDocumentPath({ basePath, pathname })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Control UI SPA fallback Accept routing", () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Control UI route classifier for base-path and root-mounted SPA serving.
|
||||
import { isControlUiFocusPath } from "@openclaw/session-url-contract";
|
||||
import { acceptsControlUiHtmlResponse, isReadHttpMethod } from "./control-ui-http-utils.js";
|
||||
import {
|
||||
classifyGatewayProbePath,
|
||||
@@ -45,6 +46,14 @@ export function isControlUiApprovalDocumentPath(params: {
|
||||
return encodedId.length > 0 && !encodedId.includes("/");
|
||||
}
|
||||
|
||||
/** Focused presentation namespace used only after plugin routing declines it. */
|
||||
export function isControlUiFocusDocumentPath(params: {
|
||||
basePath: string;
|
||||
pathname: string;
|
||||
}): boolean {
|
||||
return isControlUiFocusPath(params.pathname, params.basePath);
|
||||
}
|
||||
|
||||
/** Classify an HTTP request as Control UI serving, redirect, 404, or non-Control-UI. */
|
||||
export function classifyControlUiRequest(params: {
|
||||
basePath: string;
|
||||
|
||||
@@ -1561,33 +1561,56 @@ describe("handleControlUiHttpRequest", () => {
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "root-mounted nested routes",
|
||||
name: "root-mounted focus routes",
|
||||
requestPath: "/focus/dashboard/roboclaw/session-ref",
|
||||
basePath: undefined,
|
||||
expectedPrefix: "",
|
||||
},
|
||||
{
|
||||
name: "base-mounted focus routes",
|
||||
requestPath: "/openclaw/focus/desktop/control",
|
||||
basePath: "/openclaw",
|
||||
expectedPrefix: "/openclaw",
|
||||
},
|
||||
{
|
||||
name: "root-mounted ordinary deep routes",
|
||||
requestPath: "/settings/approvals",
|
||||
basePath: undefined,
|
||||
expectedPrefix: "",
|
||||
},
|
||||
{
|
||||
name: "base-mounted nested routes",
|
||||
name: "base-mounted ordinary deep routes",
|
||||
requestPath: "/openclaw/settings/approvals",
|
||||
basePath: "/openclaw",
|
||||
expectedPrefix: "/openclaw",
|
||||
},
|
||||
])(
|
||||
"anchors Vite-relative public asset hrefs for $name",
|
||||
"anchors Vite-relative asset references for $name",
|
||||
async ({ requestPath, basePath, expectedPrefix }) => {
|
||||
const assets = [
|
||||
const emittedAssets = [
|
||||
["index.js", "index-js\n", "application/javascript; charset=utf-8"],
|
||||
["runtime.js", "runtime-js\n", "application/javascript; charset=utf-8"],
|
||||
["index.css", "index-css\n", "text/css; charset=utf-8"],
|
||||
] as const;
|
||||
const publicAssets = [
|
||||
"favicon.svg",
|
||||
"favicon-32.png",
|
||||
"apple-touch-icon.png",
|
||||
"manifest.webmanifest",
|
||||
];
|
||||
const html = `<html><head>${assets
|
||||
const html = `<html><head>${publicAssets
|
||||
.map((asset) => `<link href="./${asset}" />`)
|
||||
.join("")}</head><body></body></html>\n`;
|
||||
.join(
|
||||
"",
|
||||
)}<link rel="modulepreload" href="./assets/runtime.js" /><link rel="stylesheet" href="./assets/index.css" /></head><body><script type="module" src="./assets/index.js"></script></body></html>\n`;
|
||||
|
||||
await withControlUiRoot({
|
||||
indexHtml: html,
|
||||
fn: async (tmp) => {
|
||||
await fs.mkdir(path.join(tmp, "assets"));
|
||||
for (const [asset, content] of emittedAssets) {
|
||||
await fs.writeFile(path.join(tmp, "assets", asset), content);
|
||||
}
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{
|
||||
@@ -1604,10 +1627,36 @@ describe("handleControlUiHttpRequest", () => {
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const body = String(end.mock.calls[0]?.[0] ?? "");
|
||||
for (const asset of assets) {
|
||||
for (const asset of publicAssets) {
|
||||
expect(body).toContain(`href="${expectedPrefix}/${asset}"`);
|
||||
expect(body).not.toContain(`href="./${asset}"`);
|
||||
}
|
||||
expect(body).toContain(`src="${expectedPrefix}/assets/index.js"`);
|
||||
expect(body).toContain(`href="${expectedPrefix}/assets/runtime.js"`);
|
||||
expect(body).toContain(`href="${expectedPrefix}/assets/index.css"`);
|
||||
expect(body).not.toContain('="./assets/');
|
||||
expect(body).not.toContain(`${requestPath}/assets/`);
|
||||
|
||||
const emittedAssetUrls = Array.from(
|
||||
body.matchAll(/(?:src|href)="([^" ]*\/assets\/[^" ]+)"/g),
|
||||
).flatMap((match) => (match[1] ? [match[1]] : []));
|
||||
expect(new Set(emittedAssetUrls)).toEqual(
|
||||
new Set(emittedAssets.map(([asset]) => `${expectedPrefix}/assets/${asset}`)),
|
||||
);
|
||||
for (const url of emittedAssetUrls) {
|
||||
const emittedAsset = emittedAssets.find(([asset]) => url.endsWith(`/${asset}`));
|
||||
expect(emittedAsset).toBeDefined();
|
||||
const [, content, contentType] = emittedAsset!;
|
||||
const response = await runControlUiRequest({
|
||||
url,
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
basePath,
|
||||
});
|
||||
expect(response.handled).toBe(true);
|
||||
expect(responseBody(response.end)).toBe(content);
|
||||
expect(response.setHeader).toHaveBeenCalledWith("Content-Type", contentType);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -3505,28 +3554,38 @@ describe("handleControlUiHttpRequest", () => {
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "root-mounted",
|
||||
name: "root-mounted approval",
|
||||
basePath: undefined,
|
||||
url: "/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E",
|
||||
},
|
||||
{
|
||||
name: "configured-base-path",
|
||||
name: "configured-base-path approval",
|
||||
basePath: "/openclaw",
|
||||
url: "/openclaw/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E",
|
||||
},
|
||||
{
|
||||
name: "asset-like-id",
|
||||
name: "asset-like approval id",
|
||||
basePath: undefined,
|
||||
url: "/approve/plugin%3Arequest.json",
|
||||
},
|
||||
{
|
||||
name: "configured-base-asset-like-id",
|
||||
name: "configured-base asset-like approval id",
|
||||
basePath: "/openclaw",
|
||||
url: "/openclaw/approve/plugin%3Arequest.js",
|
||||
},
|
||||
])("serves $name approval deep links through the SPA fallback", async ({ basePath, url }) => {
|
||||
{
|
||||
name: "root-mounted focus path",
|
||||
basePath: undefined,
|
||||
url: "/focus/dashboard/roboclaw/session.json",
|
||||
},
|
||||
{
|
||||
name: "configured-base focus path",
|
||||
basePath: "/openclaw",
|
||||
url: "/openclaw/focus/desktop/control/session/agent%3Amain%3Amain",
|
||||
},
|
||||
])("serves $name through the standalone document", async ({ basePath, url }) => {
|
||||
await withControlUiRoot({
|
||||
indexHtml: "<html><body>approval-spa</body></html>\n",
|
||||
indexHtml: "<html><body>standalone-spa</body></html>\n",
|
||||
fn: async (tmp) => {
|
||||
for (const method of ["GET", "HEAD"] as const) {
|
||||
const { res, end, handled } = await runControlUiRequest({
|
||||
@@ -3541,7 +3600,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
if (method === "HEAD") {
|
||||
expect(firstEndCallLength(end)).toBe(0);
|
||||
} else {
|
||||
expect(responseBody(end)).toContain("approval-spa");
|
||||
expect(responseBody(end)).toContain("standalone-spa");
|
||||
if (basePath) {
|
||||
expect(responseBody(end)).toContain('data-openclaw-control-ui-base-path="/openclaw"');
|
||||
}
|
||||
@@ -3553,21 +3612,31 @@ describe("handleControlUiHttpRequest", () => {
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "root-mounted",
|
||||
name: "root-mounted approval",
|
||||
basePath: undefined,
|
||||
url: "/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E",
|
||||
},
|
||||
{
|
||||
name: "configured-base-path",
|
||||
name: "configured-base-path approval",
|
||||
basePath: "/openclaw",
|
||||
url: "/openclaw/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E",
|
||||
},
|
||||
{
|
||||
name: "asset-like-id",
|
||||
name: "asset-like approval id",
|
||||
basePath: undefined,
|
||||
url: "/approve/plugin%3Arequest.json",
|
||||
},
|
||||
])("declines POST to $name approval deep links at the UI module", async ({ basePath, url }) => {
|
||||
{
|
||||
name: "root-mounted focus path",
|
||||
basePath: undefined,
|
||||
url: "/focus/terminal",
|
||||
},
|
||||
{
|
||||
name: "configured-base focus path",
|
||||
basePath: "/openclaw",
|
||||
url: "/openclaw/focus/desktop",
|
||||
},
|
||||
])("declines POST to $name at the UI module", async ({ basePath, url }) => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { handled, end } = await runControlUiRequest({
|
||||
@@ -3577,9 +3646,8 @@ describe("handleControlUiHttpRequest", () => {
|
||||
basePath,
|
||||
});
|
||||
|
||||
// The UI module only serves reads; the gateway's approval-document
|
||||
// stage (server-http.ts) owns the terminal 404 for write methods, so
|
||||
// these requests never reach plugin HTTP handlers in production.
|
||||
// The UI module serves reads only. The gateway router decides whether a
|
||||
// write is reserved approval traffic or an unclaimed focus fallback.
|
||||
expect(handled).toBe(false);
|
||||
expect(end).not.toHaveBeenCalled();
|
||||
},
|
||||
|
||||
@@ -57,7 +57,11 @@ import {
|
||||
respondNotFound as respondControlUiNotFound,
|
||||
respondPlainText,
|
||||
} from "./control-ui-http-utils.js";
|
||||
import { classifyControlUiRequest, isControlUiApprovalDocumentPath } from "./control-ui-routing.js";
|
||||
import {
|
||||
classifyControlUiRequest,
|
||||
isControlUiApprovalDocumentPath,
|
||||
isControlUiFocusDocumentPath,
|
||||
} from "./control-ui-routing.js";
|
||||
import {
|
||||
buildControlUiAvatarUrl,
|
||||
CONTROL_UI_AVATAR_PREFIX,
|
||||
@@ -122,10 +126,12 @@ const CONTROL_UI_ROOT_PUBLIC_ASSETS = new Set([
|
||||
"sw.js",
|
||||
]);
|
||||
|
||||
/** Anchors bundled public assets before deep-linked documents begin preloading. */
|
||||
function rewriteControlUiIndexHtmlPublicAssetHrefs(html: string, basePath: string): string {
|
||||
/** Anchors bundled assets before deep-linked documents begin preloading. */
|
||||
function rewriteControlUiIndexHtmlAssetHrefs(html: string, basePath: string): string {
|
||||
const normalized = normalizeControlUiBasePath(basePath);
|
||||
let next = html;
|
||||
let next = html
|
||||
.replaceAll('src="./assets/', `src="${normalized}/assets/`)
|
||||
.replaceAll('href="./assets/', `href="${normalized}/assets/`);
|
||||
for (const asset of CONTROL_UI_ROOT_PUBLIC_ASSETS) {
|
||||
const assetHref = `href="${normalized}/${asset}"`;
|
||||
// Vite's portable ./ base emits relative hrefs, which the browser starts
|
||||
@@ -666,7 +672,7 @@ async function serveResolvedIndexHtml(
|
||||
allowWasm?: boolean,
|
||||
) {
|
||||
const normalizedBasePath = normalizeControlUiBasePath(basePath);
|
||||
const withBasePath = rewriteControlUiIndexHtmlPublicAssetHrefs(body, normalizedBasePath);
|
||||
const withBasePath = rewriteControlUiIndexHtmlAssetHrefs(body, normalizedBasePath);
|
||||
const basePathAttribute = normalizedBasePath
|
||||
? ` ${CONTROL_UI_BASE_PATH_ATTRIBUTE}="${escapeHtmlAttribute(normalizedBasePath)}"`
|
||||
: "";
|
||||
@@ -956,7 +962,9 @@ export async function handleControlUiHttpRequest(
|
||||
|
||||
const uiPath =
|
||||
basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
|
||||
const approvalDocument = isControlUiApprovalDocumentPath({ basePath, pathname });
|
||||
const standaloneDocument =
|
||||
isControlUiApprovalDocumentPath({ basePath, pathname }) ||
|
||||
isControlUiFocusDocumentPath({ basePath, pathname });
|
||||
const rel = (() => {
|
||||
if (uiPath === ROOT_PREFIX) {
|
||||
return "";
|
||||
@@ -973,7 +981,7 @@ export async function handleControlUiHttpRequest(
|
||||
}
|
||||
return uiPath.slice(1);
|
||||
})();
|
||||
const requested = approvalDocument
|
||||
const requested = standaloneDocument
|
||||
? "index.html"
|
||||
: rel && !rel.endsWith("/")
|
||||
? rel
|
||||
|
||||
+23
-18
@@ -31,6 +31,7 @@ import {
|
||||
import { respondNotFound, respondPlainText } from "./control-ui-http-utils.js";
|
||||
import {
|
||||
isControlUiApprovalDocumentPath,
|
||||
isControlUiFocusDocumentPath,
|
||||
isControlUiPluginManagerRequest,
|
||||
} from "./control-ui-routing.js";
|
||||
import type { ControlUiRootState } from "./control-ui.js";
|
||||
@@ -325,6 +326,17 @@ export function createGatewayHttpServer(opts: {
|
||||
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
|
||||
root: controlUiRoot,
|
||||
});
|
||||
const handleStandaloneControlUiRequest = async () => {
|
||||
if (!controlUiEnabled) {
|
||||
respondNotFound(res);
|
||||
return true;
|
||||
}
|
||||
if (await handleControlUiRequest()) {
|
||||
return true;
|
||||
}
|
||||
respondNotFound(res);
|
||||
return true;
|
||||
};
|
||||
const requestStages: GatewayHttpRequestStage[] = [
|
||||
{
|
||||
run: () =>
|
||||
@@ -460,24 +472,15 @@ export function createGatewayHttpServer(opts: {
|
||||
config: openAiChatCompletionsConfig,
|
||||
}),
|
||||
);
|
||||
addRequestStage(
|
||||
isControlUiApprovalDocumentPath({
|
||||
basePath: controlUiBasePath,
|
||||
pathname: scopedRequestPath,
|
||||
}),
|
||||
async () => {
|
||||
if (!controlUiEnabled) {
|
||||
respondNotFound(res);
|
||||
return true;
|
||||
}
|
||||
const handled = await handleControlUiRequest();
|
||||
if (handled) {
|
||||
return true;
|
||||
}
|
||||
respondNotFound(res);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const approvalDocument = isControlUiApprovalDocumentPath({
|
||||
basePath: controlUiBasePath,
|
||||
pathname: scopedRequestPath,
|
||||
});
|
||||
const focusDocument = isControlUiFocusDocumentPath({
|
||||
basePath: controlUiBasePath,
|
||||
pathname: scopedRequestPath,
|
||||
});
|
||||
addRequestStage(approvalDocument, handleStandaloneControlUiRequest);
|
||||
addRequestStage(Boolean(nodeCapability), async () => {
|
||||
const { authorizePluginNodeCapabilityRequest } = await getPluginNodeCapabilityAuthModule();
|
||||
const ok = await authorizePluginNodeCapabilityRequest({
|
||||
@@ -581,6 +584,8 @@ export function createGatewayHttpServer(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
addRequestStage(focusDocument, handleStandaloneControlUiRequest);
|
||||
|
||||
addRequestStage(
|
||||
scopedRequestPath.startsWith("/api/chat/media/outgoing/") ||
|
||||
(controlUiRouteBasePath.length > 0 &&
|
||||
|
||||
@@ -163,6 +163,42 @@ function createRuntimeScopeRecorderHandler(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function createPublicPluginRouteHandler(params: {
|
||||
path: string;
|
||||
match: "exact" | "prefix";
|
||||
method: string;
|
||||
responseBody: string;
|
||||
}) {
|
||||
const routeHandler = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.method !== params.method) {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.end(params.responseBody);
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
routeHandler,
|
||||
handlePluginRequest: createGatewayPluginRequestHandler({
|
||||
registry: createGatewayTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "focus-owner",
|
||||
source: "focus-owner",
|
||||
path: params.path,
|
||||
auth: "plugin",
|
||||
match: params.match,
|
||||
handler: routeHandler,
|
||||
},
|
||||
],
|
||||
}),
|
||||
log: { warn: vi.fn() } as unknown as Parameters<
|
||||
typeof createGatewayPluginRequestHandler
|
||||
>[0]["log"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectPluginRequestOk(
|
||||
server: Parameters<typeof dispatchRequest>[0],
|
||||
request: Parameters<typeof createRequest>[0],
|
||||
@@ -754,6 +790,153 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: "root-mounted exact GET",
|
||||
basePath: "",
|
||||
routePath: "/focus/terminal",
|
||||
match: "exact" as const,
|
||||
requestPath: "/focus/terminal",
|
||||
method: "GET",
|
||||
},
|
||||
{
|
||||
label: "root-mounted prefix POST",
|
||||
basePath: "",
|
||||
routePath: "/focus",
|
||||
match: "prefix" as const,
|
||||
requestPath: "/focus/desktop/control",
|
||||
method: "POST",
|
||||
},
|
||||
{
|
||||
label: "base-path-mounted prefix PUT",
|
||||
basePath: "/openclaw",
|
||||
routePath: "/openclaw/focus",
|
||||
match: "prefix" as const,
|
||||
requestPath: "/openclaw/focus/dashboard/roboclaw/session-ref",
|
||||
method: "PUT",
|
||||
},
|
||||
])(
|
||||
"lets a registered $label route own focus requests",
|
||||
async ({ basePath, routePath, match, requestPath, method }) => {
|
||||
const { handlePluginRequest, routeHandler } = createPublicPluginRouteHandler({
|
||||
path: routePath,
|
||||
match,
|
||||
method,
|
||||
responseBody: "plugin-owned-focus",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-focus-ownership-test-",
|
||||
resolvedAuth: AUTH_NONE,
|
||||
overrides: {
|
||||
controlUiEnabled: true,
|
||||
controlUiBasePath: basePath,
|
||||
controlUiRoot: { kind: "missing" },
|
||||
handlePluginRequest,
|
||||
},
|
||||
run: async (server) => {
|
||||
const response = await sendRequest(server, { path: requestPath, method });
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(response.getBody()).toBe("plugin-owned-focus");
|
||||
expect(routeHandler).toHaveBeenCalledOnce();
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: "root-mounted",
|
||||
basePath: "",
|
||||
rootPath: "/focus",
|
||||
descendantPath: "/focus/desktop/control",
|
||||
lookalikePath: "/focused",
|
||||
},
|
||||
{
|
||||
label: "base-path-mounted",
|
||||
basePath: "/openclaw",
|
||||
rootPath: "/openclaw/focus",
|
||||
descendantPath: "/openclaw/focus/dashboard/roboclaw/session-ref",
|
||||
lookalikePath: "/openclaw/focused",
|
||||
},
|
||||
])(
|
||||
"uses focus as the $label unclaimed fallback without reserving lookalikes",
|
||||
async ({ basePath, rootPath, descendantPath, lookalikePath }) => {
|
||||
const { handlePluginRequest, routeHandler } = createPublicPluginRouteHandler({
|
||||
path: lookalikePath,
|
||||
match: "exact",
|
||||
method: "GET",
|
||||
responseBody: "plugin-lookalike",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-focus-fallback-test-",
|
||||
resolvedAuth: AUTH_NONE,
|
||||
overrides: {
|
||||
controlUiEnabled: true,
|
||||
controlUiBasePath: basePath,
|
||||
controlUiRoot: { kind: "missing" },
|
||||
handlePluginRequest,
|
||||
},
|
||||
run: async (server) => {
|
||||
const get = await sendRequest(server, { path: rootPath });
|
||||
expect(get.res.statusCode).toBe(503);
|
||||
expect(get.getBody()).toContain("Control UI assets not found");
|
||||
|
||||
const head = await sendRequest(server, { path: descendantPath, method: "HEAD" });
|
||||
expect(head.res.statusCode).toBe(503);
|
||||
|
||||
for (const method of ["POST", "PUT"] as const) {
|
||||
const write = await sendRequest(server, { path: descendantPath, method });
|
||||
expect(write.res.statusCode, method).toBe(404);
|
||||
expect(write.getBody(), method).toBe("Not Found");
|
||||
}
|
||||
|
||||
const lookalike = await sendRequest(server, { path: lookalikePath });
|
||||
expect(lookalike.res.statusCode).toBe(200);
|
||||
expect(lookalike.getBody()).toBe("plugin-lookalike");
|
||||
expect(routeHandler).toHaveBeenCalledOnce();
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ label: "root-mounted", basePath: "", path: "/focus/terminal" },
|
||||
{
|
||||
label: "base-path-mounted",
|
||||
basePath: "/openclaw",
|
||||
path: "/openclaw/focus/desktop",
|
||||
},
|
||||
])(
|
||||
"returns 404 for an unclaimed $label focus request when control ui serving is disabled",
|
||||
async ({ basePath, path }) => {
|
||||
const { handlePluginRequest, routeHandler } = createPublicPluginRouteHandler({
|
||||
path: `${basePath}/unrelated`,
|
||||
match: "exact",
|
||||
method: "GET",
|
||||
responseBody: "unrelated",
|
||||
});
|
||||
|
||||
await withPluginGatewayServer({
|
||||
prefix: "openclaw-plugin-http-disabled-focus-fallback-test-",
|
||||
resolvedAuth: AUTH_NONE,
|
||||
overrides: {
|
||||
controlUiEnabled: false,
|
||||
controlUiBasePath: basePath,
|
||||
handlePluginRequest,
|
||||
},
|
||||
run: async (server) => {
|
||||
const response = await sendRequest(server, { path });
|
||||
|
||||
expect(response.res.statusCode).toBe(404);
|
||||
expect(response.getBody()).toBe("Not Found");
|
||||
expect(routeHandler).not.toHaveBeenCalled();
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("passes POST webhook routes through root-mounted control ui to plugins", async () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vitest extension codex app server attempt extra config wires the extension codex app server attempt extra test shard.
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
function createExtensionCodexAppServerAttemptExtraVitestConfig(
|
||||
export function createExtensionCodexAppServerAttemptExtraVitestConfig(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
) {
|
||||
return createScopedVitestConfig(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vitest extension codex app server attempt light config wires the extension codex app server attempt light test shard.
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
function createExtensionCodexAppServerAttemptLightVitestConfig(
|
||||
export function createExtensionCodexAppServerAttemptLightVitestConfig(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
) {
|
||||
return createScopedVitestConfig(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vitest extension codex app server tools config wires the extension codex app server tools test shard.
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
function createExtensionCodexAppServerToolsVitestConfig(
|
||||
export function createExtensionCodexAppServerToolsVitestConfig(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
) {
|
||||
return createScopedVitestConfig(
|
||||
|
||||
@@ -494,6 +494,9 @@ describe("inferBasePathFromPathname", () => {
|
||||
// Real mount directories that merely contain a route-suffix keep working.
|
||||
expect(inferBasePathFromPathname("/ui/config")).toBe("/ui");
|
||||
expect(inferBasePathFromPathname("/ui/settings/appearance")).toBe("/ui");
|
||||
expect(inferBasePathFromPathname("/focus/terminal")).toBe("");
|
||||
expect(inferBasePathFromPathname("/openclaw/focus/dashboard/main")).toBe("/openclaw");
|
||||
expect(inferBasePathFromPathname("/company/focus/focus/terminal")).toBe("/company/focus");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inferControlUiFocusBasePath } from "@openclaw/session-url-contract";
|
||||
import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter";
|
||||
import type { RouteLocation } from "@openclaw/uirouter";
|
||||
import { isValidWorkboardBoardId } from "@openclaw/workboard-contract";
|
||||
@@ -310,6 +311,10 @@ function isRouteOwnedBasePath(basePath: string): boolean {
|
||||
}
|
||||
|
||||
export function inferBasePathFromPathname(pathname: string): string {
|
||||
const focusBasePath = inferControlUiFocusBasePath(pathname);
|
||||
if (focusBasePath !== null) {
|
||||
return focusBasePath;
|
||||
}
|
||||
const isMountRoot = pathname.trim().endsWith("/");
|
||||
const normalizedPath = normalizePath(pathname);
|
||||
if (normalizedPath.toLowerCase().endsWith("/index.html")) {
|
||||
|
||||
+226
-45
@@ -1,4 +1,6 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { buildControlUiFocusPath, type ControlUiFocusTarget } from "@openclaw/session-url-contract";
|
||||
import type { RouteLocation, RouteNotFound } from "@openclaw/uirouter";
|
||||
import { html, nothing } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
@@ -9,16 +11,15 @@ import "../components/login-gate.ts";
|
||||
import "../components/openclaw-mascot.ts";
|
||||
import { installNativeTitleGuard } from "../components/tooltip.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { formatUiError } from "../lib/format-error.ts";
|
||||
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
|
||||
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
|
||||
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
|
||||
import type { ChatRouteData } from "../pages/chat/route-loader.ts";
|
||||
import { isDesktopPanelAvailable } from "./app-shell-chrome.ts";
|
||||
import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts";
|
||||
import { resolveControlUiBasePath } from "./browser.ts";
|
||||
import { applicationContext, type ApplicationContext } from "./context.ts";
|
||||
import { dashboardDocumentSession, isDashboardOnlyView } from "./dashboard-document-mode.ts";
|
||||
import { desktopDocumentOptions, isDesktopOnlyView } from "./desktop-document-mode.ts";
|
||||
import {
|
||||
APPROVAL_PAGE_ELEMENT,
|
||||
DASHBOARD_DOCUMENT_ELEMENT,
|
||||
@@ -29,7 +30,21 @@ import {
|
||||
} from "./lazy-custom-element.ts";
|
||||
import { resolveOnboardingMode } from "./onboarding-mode.ts";
|
||||
import { controlUiPublicAssetPath } from "./public-assets.ts";
|
||||
import { isTerminalOnlyView } from "./terminal-document-mode.ts";
|
||||
|
||||
type FocusDashboardRouteState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "not-found" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ambiguous"; data: Extract<ChatRouteData, { kind: "ambiguous" }> }
|
||||
| { kind: "session"; data: Extract<ChatRouteData, { kind: "session" }> };
|
||||
|
||||
function routeLocationHref(location: RouteLocation): string {
|
||||
return `${location.pathname}${location.search}${location.hash}`;
|
||||
}
|
||||
|
||||
function isRouteNotFound(result: ChatRouteData | RouteNotFound): result is RouteNotFound {
|
||||
return "type" in result && result.type === "notFound";
|
||||
}
|
||||
|
||||
export function resolveTerminalThemeMode(): "dark" | "light" {
|
||||
return document.documentElement.dataset.themeMode === "light" ? "light" : "dark";
|
||||
@@ -79,18 +94,8 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
@state() private loginShowGatewayPassword = false;
|
||||
@state() private pendingGatewayUrl: string | null = null;
|
||||
@state() private onboarding = resolveOnboardingMode(globalThis.location?.search ?? "");
|
||||
@state() private focusDashboardRoute: FocusDashboardRouteState = { kind: "loading" };
|
||||
|
||||
private readonly terminalOnly = isTerminalOnlyView(
|
||||
globalThis.location,
|
||||
resolveControlUiBasePath(globalThis.location?.pathname ?? "/"),
|
||||
);
|
||||
private readonly desktopOnly = isDesktopOnlyView(
|
||||
globalThis.location,
|
||||
resolveControlUiBasePath(globalThis.location?.pathname ?? "/"),
|
||||
);
|
||||
private readonly dashboardOnly = isDashboardOnlyView(globalThis.location);
|
||||
private readonly dashboardSession = dashboardDocumentSession(globalThis.location);
|
||||
private readonly desktopOptions = desktopDocumentOptions(globalThis.location);
|
||||
private runtime: ApplicationRuntime | undefined;
|
||||
private readonly contextProvider = new ContextProvider(this, {
|
||||
context: applicationContext,
|
||||
@@ -98,11 +103,21 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
private readonly subscriptions = new SubscriptionsController(this);
|
||||
private loginGatewaySource: ApplicationContext["gateway"] | null = null;
|
||||
private loginConnectionClient: GatewayBrowserClient | null = null;
|
||||
private focusDashboardAbort: AbortController | null = null;
|
||||
|
||||
private get context(): ApplicationContext<RouteId> | undefined {
|
||||
return this.runtime?.context;
|
||||
}
|
||||
|
||||
private get focusTarget(): ControlUiFocusTarget | null {
|
||||
const focus = this.runtime?.focusLocation;
|
||||
return focus?.status === "valid" ? focus.target : null;
|
||||
}
|
||||
|
||||
private get terminalOnly(): boolean {
|
||||
return this.focusTarget?.kind === "terminal";
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.subscriptions
|
||||
@@ -128,13 +143,14 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
void import("../components/session-progress-hovercard-registration.ts");
|
||||
this.resetLoginSensitivePresentation();
|
||||
this.runtime = bootstrapApplication();
|
||||
if (this.terminalOnly) {
|
||||
const focusTarget = this.focusTarget;
|
||||
if (focusTarget?.kind === "terminal") {
|
||||
preloadOptionalElement(this, TERMINAL_PANEL_ELEMENT);
|
||||
}
|
||||
if (this.desktopOnly) {
|
||||
if (focusTarget?.kind === "desktop") {
|
||||
preloadOptionalElement(this, DESKTOP_PANEL_ELEMENT);
|
||||
}
|
||||
if (this.dashboardOnly) {
|
||||
if (focusTarget?.kind === "dashboard") {
|
||||
preloadOptionalElement(this, DASHBOARD_DOCUMENT_ELEMENT);
|
||||
}
|
||||
if (this.runtime.documentMode?.kind === "approval") {
|
||||
@@ -149,14 +165,19 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
// The runtime is created after controller hostConnected hooks run. Ensure
|
||||
// their lazy source getters bind on both the initial mount and reconnect.
|
||||
this.requestUpdate();
|
||||
void this.runtime.start().catch((error: unknown) => {
|
||||
console.error("[openclaw] application start failed", error);
|
||||
});
|
||||
void this.runtime
|
||||
.start()
|
||||
.then(() => this.resolveFocusDashboard())
|
||||
.catch((error: unknown) => {
|
||||
console.error("[openclaw] application start failed", error);
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
// Stop reactive subscriptions before disposing their application sources.
|
||||
this.subscriptions.clear();
|
||||
this.focusDashboardAbort?.abort();
|
||||
this.focusDashboardAbort = null;
|
||||
this.runtime?.stop();
|
||||
this.runtime = undefined;
|
||||
this.loginGatewaySource = null;
|
||||
@@ -216,6 +237,159 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
}
|
||||
}
|
||||
|
||||
private renderFocusEscape(label: string) {
|
||||
return html`<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
@click=${() => this.closeDocument(this.context?.basePath ?? "")}
|
||||
>
|
||||
${label}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private replaceFocusDashboardLocation(location: RouteLocation, source: RouteLocation): void {
|
||||
const basePath = this.context?.basePath ?? "";
|
||||
const expected = buildControlUiFocusPath(
|
||||
{ kind: "dashboard", path: routeLocationHref(source) },
|
||||
basePath,
|
||||
);
|
||||
const replacement = buildControlUiFocusPath(
|
||||
{ kind: "dashboard", path: routeLocationHref(location) },
|
||||
basePath,
|
||||
);
|
||||
const current = `${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`;
|
||||
if (!expected || !replacement || current !== expected || replacement === current) {
|
||||
return;
|
||||
}
|
||||
globalThis.history.replaceState(globalThis.history.state, "", replacement);
|
||||
}
|
||||
|
||||
private async resolveFocusDashboard(): Promise<void> {
|
||||
const target = this.focusTarget;
|
||||
const context = this.context;
|
||||
if (target?.kind !== "dashboard" || !context) {
|
||||
return;
|
||||
}
|
||||
this.focusDashboardAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
this.focusDashboardAbort = controller;
|
||||
this.focusDashboardRoute = { kind: "loading" };
|
||||
const location = target.route;
|
||||
try {
|
||||
const { loadChatRoute } = await import("../pages/chat/route-loader.ts");
|
||||
const result = await loadChatRoute(context, location, "dashboard", controller.signal);
|
||||
if (controller.signal.aborted || this.focusDashboardAbort !== controller) {
|
||||
return;
|
||||
}
|
||||
if (isRouteNotFound(result)) {
|
||||
this.focusDashboardRoute = { kind: "not-found" };
|
||||
return;
|
||||
}
|
||||
if (result.kind === "ambiguous") {
|
||||
this.focusDashboardRoute = {
|
||||
kind: "ambiguous",
|
||||
data: {
|
||||
...result,
|
||||
candidates: result.candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
href:
|
||||
buildControlUiFocusPath(
|
||||
{ kind: "dashboard", path: candidate.href },
|
||||
context.basePath,
|
||||
) ?? candidate.href,
|
||||
})),
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
this.focusDashboardRoute = { kind: "session", data: result };
|
||||
if (result.canonicalLocation && result.canonicalLocationSource) {
|
||||
this.replaceFocusDashboardLocation(
|
||||
result.canonicalLocation,
|
||||
result.canonicalLocationSource,
|
||||
);
|
||||
}
|
||||
const canonicalLocationSource = result.canonicalLocationSource;
|
||||
if (result.canonicalLocationReady && canonicalLocationSource) {
|
||||
void result.canonicalLocationReady.then((canonicalLocation) => {
|
||||
if (
|
||||
canonicalLocation &&
|
||||
!controller.signal.aborted &&
|
||||
this.focusDashboardAbort === controller
|
||||
) {
|
||||
this.replaceFocusDashboardLocation(canonicalLocation, canonicalLocationSource);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted && this.focusDashboardAbort === controller) {
|
||||
this.focusDashboardRoute = { kind: "error", message: formatUiError(error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderFocusDashboard(
|
||||
gatewaySnapshot: ApplicationContext["gateway"]["snapshot"],
|
||||
gatewayConnected: boolean,
|
||||
gatewayStartupStatus: string | undefined,
|
||||
) {
|
||||
const route = this.focusDashboardRoute;
|
||||
if (route.kind === "loading") {
|
||||
return renderConnectingSplash(gatewayStartupStatus);
|
||||
}
|
||||
if (route.kind === "not-found") {
|
||||
return html`<main class="board-document">
|
||||
<section class="board-document__state stack" role="status">
|
||||
<span>${t("dashboardDocument.notFound")}</span>
|
||||
${this.renderFocusEscape(t("dashboardDocument.close"))}
|
||||
</section>
|
||||
</main>`;
|
||||
}
|
||||
if (route.kind === "error") {
|
||||
return html`<main class="board-document">
|
||||
<section class="board-document__state board-document__state--error stack" role="alert">
|
||||
<span>${t("dashboardDocument.loadFailed", { error: route.message })}</span>
|
||||
${this.renderFocusEscape(t("dashboardDocument.close"))}
|
||||
</section>
|
||||
</main>`;
|
||||
}
|
||||
if (route.kind === "ambiguous") {
|
||||
return html`<main class="board-document">
|
||||
<section class="card board-document__state">
|
||||
<h2>${t("chat.sessionRoute.chooseTitle")}</h2>
|
||||
<p>
|
||||
${route.data.candidates.length > 1
|
||||
? t("chat.sessionRoute.multipleMatches", { shortId: route.data.shortId })
|
||||
: t("chat.sessionRoute.additionalMatches")}
|
||||
</p>
|
||||
${route.data.candidates.map(
|
||||
(candidate) => html`<p>
|
||||
<a href=${candidate.href}>${candidate.displayName}</a><br />
|
||||
<small>${candidate.agentId} · ${candidate.idPrefix}</small>
|
||||
</p>`,
|
||||
)}
|
||||
${route.data.truncated
|
||||
? html`<p><small>${t("chat.sessionRoute.additionalMatches")}</small></p>`
|
||||
: nothing}
|
||||
${this.renderFocusEscape(t("dashboardDocument.close"))}
|
||||
</section>
|
||||
</main>`;
|
||||
}
|
||||
return html`
|
||||
<openclaw-board-document
|
||||
.gatewaySnapshot=${gatewaySnapshot}
|
||||
.sessionKey=${route.data.sessionKey}
|
||||
.onDocumentClose=${() => this.closeDocument(this.context?.basePath ?? "")}
|
||||
></openclaw-board-document>
|
||||
${!gatewayConnected && gatewaySnapshot.lastError === null
|
||||
? renderConnectingSplash(gatewayStartupStatus)
|
||||
: nothing}
|
||||
${!isOptionalElementDefined(DASHBOARD_DOCUMENT_ELEMENT) && gatewayConnected
|
||||
? renderConnectingSplash(gatewayStartupStatus)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const context = this.context;
|
||||
const runtime = this.runtime;
|
||||
@@ -243,9 +417,18 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
></openclaw-gateway-url-confirmation>
|
||||
`
|
||||
: nothing;
|
||||
// Full-screen terminals own the whole document. Keep the generic login gate
|
||||
if (runtime.focusLocation?.status === "unsupported") {
|
||||
return html`<main class="connect-splash" role="alert">
|
||||
<div class="stack">
|
||||
<span class="connect-splash__status">${t("focus.unsupported")}</span>
|
||||
${this.renderFocusEscape(t("common.back"))}
|
||||
</div>
|
||||
</main>`;
|
||||
}
|
||||
const focusTarget = this.focusTarget;
|
||||
// Focused terminals own the whole document. Keep the generic login gate
|
||||
// out of this path or a connecting native session exposes Web UI chrome.
|
||||
if (this.terminalOnly) {
|
||||
if (focusTarget?.kind === "terminal") {
|
||||
const terminalAvailable = isTerminalAvailable(
|
||||
gatewaySnapshot,
|
||||
context.config.current.terminalEnabled ?? false,
|
||||
@@ -269,23 +452,30 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
? renderConnectingSplash(gatewayStartupStatus)
|
||||
: nothing}
|
||||
${!terminalAvailable && (gatewayConnected || gatewaySnapshot.lastError)
|
||||
? html`<div class="terminal-view-unavailable">${t("terminal.unavailable")}</div>`
|
||||
? html`<div class="terminal-view-unavailable">
|
||||
<div class="stack">
|
||||
<span>${t("terminal.unavailable")}</span>
|
||||
${this.renderFocusEscape(t("common.back"))}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
// Desktop documents share the panel's connection owner but none of its
|
||||
// dock or shell chrome. Native clients can therefore load this route as a
|
||||
// standalone, mobile-shaped surface without changing the observe contract.
|
||||
if (this.desktopOnly) {
|
||||
if (focusTarget?.kind === "desktop") {
|
||||
const desktopAvailable = isDesktopPanelAvailable(gatewaySnapshot);
|
||||
const source = focusTarget.selector?.kind === "source" ? focusTarget.selector.value : null;
|
||||
const session = focusTarget.selector?.kind === "session" ? focusTarget.selector.value : null;
|
||||
return html`
|
||||
<openclaw-desktop-panel
|
||||
.client=${gatewayConnected ? gatewaySnapshot.client : null}
|
||||
.available=${desktopAvailable}
|
||||
.documentMode=${true}
|
||||
.documentSource=${this.desktopOptions.source}
|
||||
.documentSession=${this.desktopOptions.session}
|
||||
.documentControl=${this.desktopOptions.control}
|
||||
.documentSource=${source}
|
||||
.documentSession=${session}
|
||||
.documentControl=${focusTarget.control}
|
||||
.onDocumentClose=${() => this.closeDocument(context.basePath)}
|
||||
></openclaw-desktop-panel>
|
||||
${!gatewayConnected && gatewaySnapshot.lastError === null
|
||||
@@ -295,26 +485,17 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
? renderConnectingSplash(gatewayStartupStatus)
|
||||
: nothing}
|
||||
${!desktopAvailable && (gatewayConnected || gatewaySnapshot.lastError)
|
||||
? html`<div class="desktop-view-unavailable">${t("desktop.unavailable")}</div>`
|
||||
? html`<div class="desktop-view-unavailable">
|
||||
<div class="stack">
|
||||
<span>${t("desktop.unavailable")}</span>
|
||||
${this.renderFocusEscape(t("common.back"))}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
// Dashboard documents reuse the live board provider and widget bridge while
|
||||
// keeping the application shell, transcript, and navigation chrome unmounted.
|
||||
if (this.dashboardOnly) {
|
||||
return html`
|
||||
<openclaw-board-document
|
||||
.gatewaySnapshot=${gatewaySnapshot}
|
||||
.sessionKey=${this.dashboardSession}
|
||||
.onDocumentClose=${() => this.closeDocument(context.basePath)}
|
||||
></openclaw-board-document>
|
||||
${!gatewayConnected && gatewaySnapshot.lastError === null
|
||||
? renderConnectingSplash(gatewayStartupStatus)
|
||||
: nothing}
|
||||
${!isOptionalElementDefined(DASHBOARD_DOCUMENT_ELEMENT) && gatewayConnected
|
||||
? renderConnectingSplash(gatewayStartupStatus)
|
||||
: nothing}
|
||||
`;
|
||||
if (focusTarget?.kind === "dashboard") {
|
||||
return this.renderFocusDashboard(gatewaySnapshot, gatewayConnected, gatewayStartupStatus);
|
||||
}
|
||||
// In the normal Control UI document, the Gateway lifecycle owns unresolved
|
||||
// first-connect state across every auth mode. Failures publish lastError
|
||||
|
||||
@@ -682,6 +682,7 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
.client=${gatewayConnected ? gatewaySnapshot.client : null}
|
||||
.available=${desktopPanelAvailable}
|
||||
.suppressed=${settingsTakeover}
|
||||
.basePath=${context.basePath}
|
||||
></openclaw-desktop-panel>
|
||||
`}
|
||||
<openclaw-custodian-panel
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { bootstrapApplication } from "./bootstrap.ts";
|
||||
import type { ApplicationContext } from "./context.ts";
|
||||
import { loadSettings, saveSettings } from "./settings.ts";
|
||||
import { normalizeLegacyTerminalViewLocation } from "./startup-settings.ts";
|
||||
|
||||
// Startup progress (dynamic imports, gateway subscribe, router start) is not a
|
||||
// performance assertion, so these waits must not inherit vi.waitFor's 1s default:
|
||||
@@ -28,6 +29,39 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("normalizeLegacyTerminalViewLocation", () => {
|
||||
it.each([
|
||||
{
|
||||
location: { pathname: "/", search: "?view=terminal&keep=yes", hash: "#pane" },
|
||||
basePath: "",
|
||||
expected: { pathname: "/focus/terminal", search: "?keep=yes", hash: "#pane" },
|
||||
},
|
||||
{
|
||||
location: {
|
||||
pathname: "/openclaw/",
|
||||
search: "?keep=yes&view=terminal",
|
||||
hash: "#pane",
|
||||
},
|
||||
basePath: "/openclaw",
|
||||
expected: {
|
||||
pathname: "/openclaw/focus/terminal",
|
||||
search: "?keep=yes",
|
||||
hash: "#pane",
|
||||
},
|
||||
},
|
||||
])("normalizes the released terminal query at $basePath", ({ location, basePath, expected }) => {
|
||||
expect(normalizeLegacyTerminalViewLocation(location, basePath)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ pathname: "/", search: "?view=desktop", hash: "" },
|
||||
{ pathname: "/", search: "?view=dashboard", hash: "" },
|
||||
{ pathname: "/settings/appearance", search: "?view=terminal", hash: "" },
|
||||
])("does not normalize an unsupported legacy location $pathname$search", (location) => {
|
||||
expect(normalizeLegacyTerminalViewLocation(location, "")).toBe(location);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInitialApplicationLocation", () => {
|
||||
it("routes an opaque persisted key without aborting bootstrap", () => {
|
||||
expect(
|
||||
@@ -521,17 +555,22 @@ describe("normalizeInitialApplicationLocation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the terminal document route outside the application router", async () => {
|
||||
it("keeps the focused terminal route outside the application router", async () => {
|
||||
const previousSettings = loadSettings();
|
||||
const previousUrl = window.location.href;
|
||||
window.history.replaceState({}, "", "/terminal");
|
||||
window.history.replaceState({}, "", "/focus/terminal");
|
||||
const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() });
|
||||
const routerStart = vi.spyOn(runtime.router, "start");
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
|
||||
expect(window.location.pathname).toBe("/terminal");
|
||||
expect(window.location.pathname).toBe("/focus/terminal");
|
||||
expect(runtime.focusLocation).toEqual({
|
||||
status: "valid",
|
||||
basePath: "",
|
||||
target: { kind: "terminal" },
|
||||
});
|
||||
expect(routerStart).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
runtime.stop();
|
||||
@@ -540,6 +579,119 @@ describe("normalizeInitialApplicationLocation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
initialUrl: "/?view=terminal&keep=yes#pane",
|
||||
expectedUrl: "/focus/terminal?keep=yes#pane",
|
||||
basePath: "",
|
||||
},
|
||||
{
|
||||
initialUrl: "/openclaw/?view=terminal&keep=yes#pane",
|
||||
expectedUrl: "/openclaw/focus/terminal?keep=yes#pane",
|
||||
basePath: "/openclaw",
|
||||
},
|
||||
])(
|
||||
"rewrites the released terminal query at the $basePath application boundary",
|
||||
async ({ initialUrl, expectedUrl, basePath }) => {
|
||||
const previousSettings = loadSettings();
|
||||
const previousUrl = window.location.href;
|
||||
window.history.replaceState({}, "", initialUrl);
|
||||
const replaceState = vi.spyOn(window.history, "replaceState");
|
||||
const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() });
|
||||
const routerStart = vi.spyOn(runtime.router, "start");
|
||||
|
||||
try {
|
||||
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
|
||||
expectedUrl,
|
||||
);
|
||||
expect(runtime.focusLocation).toEqual({
|
||||
status: "valid",
|
||||
basePath,
|
||||
target: { kind: "terminal" },
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
expect(routerStart).not.toHaveBeenCalled();
|
||||
expect(replaceState).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
runtime.stop();
|
||||
replaceState.mockRestore();
|
||||
window.history.replaceState({}, "", previousUrl);
|
||||
saveSettings(previousSettings);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["desktop", "dashboard"])(
|
||||
"does not recognize the removed %s query presentation",
|
||||
(view) => {
|
||||
const previousSettings = loadSettings();
|
||||
const previousUrl = window.location.href;
|
||||
const initialUrl = `/?view=${view}&keep=yes#pane`;
|
||||
window.history.replaceState({}, "", initialUrl);
|
||||
const replaceState = vi.spyOn(window.history, "replaceState");
|
||||
const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() });
|
||||
|
||||
try {
|
||||
expect(runtime.focusLocation).toBeNull();
|
||||
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
|
||||
initialUrl,
|
||||
);
|
||||
expect(replaceState).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
runtime.stop();
|
||||
replaceState.mockRestore();
|
||||
window.history.replaceState({}, "", previousUrl);
|
||||
saveSettings(previousSettings);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("strips startup credentials before rewriting the released terminal query", () => {
|
||||
const previousSettings = loadSettings();
|
||||
const previousUrl = window.location.href;
|
||||
window.history.replaceState({}, "", "/?view=terminal#token=startup-token&pane=1");
|
||||
const replaceState = vi.spyOn(window.history, "replaceState");
|
||||
const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() });
|
||||
|
||||
try {
|
||||
expect(replaceState.mock.calls.map((call) => call[2])).toEqual([
|
||||
"/?view=terminal#pane=1",
|
||||
"/focus/terminal#pane=1",
|
||||
]);
|
||||
expect(runtime.focusLocation).toEqual({
|
||||
status: "valid",
|
||||
basePath: "",
|
||||
target: { kind: "terminal" },
|
||||
});
|
||||
} finally {
|
||||
runtime.stop();
|
||||
replaceState.mockRestore();
|
||||
window.history.replaceState({}, "", previousUrl);
|
||||
saveSettings(previousSettings);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not recognize the terminal query outside the application root", () => {
|
||||
const previousSettings = loadSettings();
|
||||
const previousUrl = window.location.href;
|
||||
const initialUrl = "/settings/appearance?view=terminal&keep=yes#pane";
|
||||
window.history.replaceState({}, "", initialUrl);
|
||||
const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() });
|
||||
|
||||
try {
|
||||
expect(runtime.focusLocation).toBeNull();
|
||||
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
|
||||
initialUrl,
|
||||
);
|
||||
} finally {
|
||||
runtime.stop();
|
||||
window.history.replaceState({}, "", previousUrl);
|
||||
saveSettings(previousSettings);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the latest navigation requested before router start", async () => {
|
||||
const previousSettings = loadSettings();
|
||||
const previousUrl = window.location.href;
|
||||
|
||||
+36
-31
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
parseControlUiFocusLocation,
|
||||
type ControlUiFocusLocation,
|
||||
} from "@openclaw/session-url-contract";
|
||||
import type { RouteLocation } from "@openclaw/uirouter";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import { sessionRouteNamespaceFromPath } from "../app-route-paths.ts";
|
||||
@@ -39,8 +43,6 @@ import type {
|
||||
ApplicationThemeServerSelection,
|
||||
} from "./context.ts";
|
||||
import { syncCustomThemeStyleTag } from "./custom-theme.ts";
|
||||
import { isDashboardOnlyView } from "./dashboard-document-mode.ts";
|
||||
import { isDesktopDocumentPath, isDesktopOnlyView } from "./desktop-document-mode.ts";
|
||||
import { createApplicationGateway } from "./gateway-store.ts";
|
||||
import { createInitialUserMessageHandoff } from "./initial-user-message-handoff.ts";
|
||||
import { createNativeChatDrafts } from "./native-bridge.ts";
|
||||
@@ -58,8 +60,10 @@ import {
|
||||
} from "./settings.ts";
|
||||
import { createSkillWorkshopRevisionAdmissions } from "./skill-workshop-revision-admissions.ts";
|
||||
import { createStartupLifecycle, type StartupStep } from "./startup-lifecycle.ts";
|
||||
import { resolveApplicationStartupSettings } from "./startup-settings.ts";
|
||||
import { isTerminalDocumentPath, isTerminalOnlyView } from "./terminal-document-mode.ts";
|
||||
import {
|
||||
normalizeLegacyTerminalViewLocation,
|
||||
resolveApplicationStartupSettings,
|
||||
} from "./startup-settings.ts";
|
||||
import { startThemeTransition } from "./theme-transition.ts";
|
||||
import { resolveTheme, type ThemeMode } from "./theme.ts";
|
||||
import { createWebPushCapability } from "./web-push.ts";
|
||||
@@ -222,6 +226,7 @@ export type ApplicationRuntime = {
|
||||
readonly context: ApplicationContext<RouteId>;
|
||||
readonly router: ApplicationRouter;
|
||||
readonly documentMode: ApprovalDocumentMode | null;
|
||||
readonly focusLocation: ControlUiFocusLocation | null;
|
||||
readonly pendingGatewayConnection: {
|
||||
readonly gatewayUrl: string;
|
||||
readonly token: string;
|
||||
@@ -271,27 +276,26 @@ export function bootstrapApplication(
|
||||
saveSettings(startup.settings);
|
||||
}
|
||||
}
|
||||
const basePath = resolveControlUiBasePath(
|
||||
startup.location.pathname || globalThis.location?.pathname || "/",
|
||||
const applicationLocation = normalizeLegacyTerminalViewLocation(
|
||||
startup.location,
|
||||
initialBasePath,
|
||||
);
|
||||
const dashboardDocument = isDashboardOnlyView(startup.location);
|
||||
const standaloneDocument =
|
||||
isTerminalDocumentPath(startup.location.pathname, basePath) ||
|
||||
isDesktopDocumentPath(startup.location.pathname, basePath) ||
|
||||
dashboardDocument;
|
||||
if (applicationLocation !== startup.location) {
|
||||
history.replace(applicationLocation);
|
||||
}
|
||||
const basePath = resolveControlUiBasePath(
|
||||
applicationLocation.pathname || globalThis.location?.pathname || "/",
|
||||
);
|
||||
const focusLocation = parseControlUiFocusLocation(applicationLocation, basePath);
|
||||
const firstRunDefaultLanding =
|
||||
documentMode === null && isDefaultChatLanding(startup.location, basePath, routeIdFromPath);
|
||||
// A `?view=` document mode still lands on the chat path, so it counts as the default landing
|
||||
// for routing, but it is an explicit destination that renders its own surface. Redirecting it
|
||||
// into model setup strands native app webviews on a blank page, so only gate the redirect.
|
||||
const firstRunRedirectEnabled =
|
||||
firstRunDefaultLanding &&
|
||||
!isTerminalOnlyView(startup.location, basePath) &&
|
||||
!isDesktopOnlyView(startup.location, basePath) &&
|
||||
!dashboardDocument;
|
||||
documentMode === null &&
|
||||
focusLocation === null &&
|
||||
isDefaultChatLanding(applicationLocation, basePath, routeIdFromPath);
|
||||
const firstRunRedirectEnabled = firstRunDefaultLanding;
|
||||
const sessionPathBuilderReady =
|
||||
dependencies.sessionPathBuilderReady ??
|
||||
(documentMode || dashboardDocument
|
||||
(documentMode ||
|
||||
(focusLocation?.status === "valid" && focusLocation.target.kind !== "dashboard")
|
||||
? Promise.resolve()
|
||||
: import("@openclaw/session-url-contract").then((contract) => {
|
||||
setSessionPathBuilder(contract.buildControlUiSessionPath);
|
||||
@@ -313,23 +317,23 @@ export function bootstrapApplication(
|
||||
);
|
||||
const agents = createAgentCapability(gateway);
|
||||
const startupLifecycle = createStartupLifecycle();
|
||||
const startupRouteId = routeIdFromPath(startup.location.pathname, basePath);
|
||||
const startupRouteId = routeIdFromPath(applicationLocation.pathname, basePath);
|
||||
const releasedSessionQuery =
|
||||
(startupRouteId === "chat" || startupRouteId === "dashboard") &&
|
||||
sessionRouteNamespaceFromPath(startup.location.pathname, basePath) === null &&
|
||||
new URLSearchParams(startup.location.search).has("session");
|
||||
sessionRouteNamespaceFromPath(applicationLocation.pathname, basePath) === null &&
|
||||
new URLSearchParams(applicationLocation.search).has("session");
|
||||
const deferInitialLocationUntilGateway =
|
||||
documentMode === null &&
|
||||
!releasedSessionQuery &&
|
||||
firstRunDefaultLanding &&
|
||||
!parseAgentSessionKey(settings.sessionKey);
|
||||
const initialLocationReady = (
|
||||
documentMode || dashboardDocument
|
||||
? Promise.resolve(startup.location)
|
||||
documentMode || focusLocation
|
||||
? Promise.resolve(applicationLocation)
|
||||
: Promise.all([sessionPathBuilderReady, import("./bootstrap-location.ts")]).then(
|
||||
([, location]) =>
|
||||
location.resolveInitialApplicationLocation({
|
||||
location: startup.location,
|
||||
location: applicationLocation,
|
||||
basePath,
|
||||
sessionKey: settings.sessionKey,
|
||||
gateway,
|
||||
@@ -341,7 +345,7 @@ export function bootstrapApplication(
|
||||
// stop() aborts an eager unscoped-session lookup even when start() returns
|
||||
// at the lazy-chunk guard, so consume that teardown-only rejection here.
|
||||
if (startupLifecycle.signal.aborted) {
|
||||
return startup.location;
|
||||
return applicationLocation;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
@@ -391,9 +395,9 @@ export function bootstrapApplication(
|
||||
const chatAttachmentHandoff = createChatAttachmentHandoff();
|
||||
applyThemePresentation(settings);
|
||||
const router = createApplicationRouter();
|
||||
// Standalone terminal, desktop, and dashboard documents render before the
|
||||
// shell; starting the page router would rewrite them to an application route.
|
||||
const startsApplicationRouter = documentMode === null && !standaloneDocument;
|
||||
// Focus documents render before the shell; starting the application router
|
||||
// would rewrite their reserved presentation route into an ordinary page.
|
||||
const startsApplicationRouter = documentMode === null && focusLocation === null;
|
||||
let routerStarted = false;
|
||||
// Pre-start navigations are invisible to history; retain the latest request so
|
||||
// router.start() cannot resolve the stale browser URL over the user's route.
|
||||
@@ -532,6 +536,7 @@ export function bootstrapApplication(
|
||||
context,
|
||||
router,
|
||||
documentMode,
|
||||
focusLocation,
|
||||
get pendingGatewayConnection() {
|
||||
return pendingGatewayConnection;
|
||||
},
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { normalizeRouteBasePath } from "@openclaw/uirouter";
|
||||
|
||||
type DashboardDocumentLocation = Pick<Location, "search">;
|
||||
|
||||
export function isDashboardOnlyView(
|
||||
location: DashboardDocumentLocation | undefined = globalThis.location,
|
||||
): boolean {
|
||||
return new URLSearchParams(location?.search ?? "").get("view") === "dashboard";
|
||||
}
|
||||
|
||||
export function dashboardDocumentSession(
|
||||
location: DashboardDocumentLocation | undefined = globalThis.location,
|
||||
): string | null {
|
||||
return new URLSearchParams(location?.search ?? "").get("session");
|
||||
}
|
||||
|
||||
export function dashboardDocumentHref(basePath: string, sessionRef: string): string {
|
||||
const path = normalizeRouteBasePath(basePath) || "/";
|
||||
const search = new URLSearchParams({ view: "dashboard", session: sessionRef });
|
||||
return `${path}?${search}`;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GatewaySessionRow } from "../api/types.ts";
|
||||
import { resolveDesktopDocumentTarget } from "../components/desktop/desktop-source.ts";
|
||||
import {
|
||||
dashboardDocumentHref,
|
||||
dashboardDocumentSession,
|
||||
isDashboardOnlyView,
|
||||
} from "./dashboard-document-mode.ts";
|
||||
import { desktopDocumentOptions } from "./desktop-document-mode.ts";
|
||||
|
||||
describe("dashboard document mode", () => {
|
||||
it("parses the dashboard session reference", () => {
|
||||
const location = {
|
||||
search: "?view=dashboard&session=agent%3Amain%3Awork",
|
||||
};
|
||||
|
||||
expect(isDashboardOnlyView(location)).toBe(true);
|
||||
expect(dashboardDocumentSession(location)).toBe("agent:main:work");
|
||||
});
|
||||
|
||||
it("keeps a missing session visible to the document empty state", () => {
|
||||
const location = { search: "?view=dashboard" };
|
||||
|
||||
expect(isDashboardOnlyView(location)).toBe(true);
|
||||
expect(dashboardDocumentSession(location)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat an ordinary route as a dashboard document", () => {
|
||||
expect(isDashboardOnlyView({ search: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("builds an encoded base-path-aware document URL", () => {
|
||||
expect(dashboardDocumentHref("/openclaw/", "agent:main:work item")).toBe(
|
||||
"/openclaw?view=dashboard&session=agent%3Amain%3Awork+item",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop document mode", () => {
|
||||
it("parses desktop source, session, and control options", () => {
|
||||
expect(
|
||||
desktopDocumentOptions({
|
||||
search: "?view=desktop&source=gateway&session=agent%3Amain%3Awork&control=1",
|
||||
}),
|
||||
).toEqual({
|
||||
source: "gateway",
|
||||
session: "agent:main:work",
|
||||
control: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an explicit source over the session placement", () => {
|
||||
const session = {
|
||||
key: "agent:main:work",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
execNode: "workstation",
|
||||
} satisfies GatewaySessionRow;
|
||||
|
||||
expect(
|
||||
resolveDesktopDocumentTarget(
|
||||
{ source: "gateway", session: session.key, control: false },
|
||||
session,
|
||||
),
|
||||
).toBe("gateway");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"cloud placement",
|
||||
{
|
||||
key: "agent:main:cloud",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
placement: { state: "active", environmentId: "worker:cloud-1" },
|
||||
} as GatewaySessionRow,
|
||||
"worker:cloud-1",
|
||||
],
|
||||
[
|
||||
"execution node",
|
||||
{
|
||||
key: "agent:main:node",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
execNode: "workstation",
|
||||
} satisfies GatewaySessionRow,
|
||||
"node:workstation",
|
||||
],
|
||||
[
|
||||
"gateway fallback",
|
||||
{
|
||||
key: "agent:main:gateway",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
} satisfies GatewaySessionRow,
|
||||
"gateway",
|
||||
],
|
||||
])("resolves a session's %s through the chat placement owner", (_label, session, expected) => {
|
||||
expect(
|
||||
resolveDesktopDocumentTarget({ source: null, session: session.key, control: false }, session),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns no target for an unknown session", () => {
|
||||
expect(
|
||||
resolveDesktopDocumentTarget(
|
||||
{ source: null, session: "agent:main:missing", control: false },
|
||||
undefined,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter";
|
||||
|
||||
const DESKTOP_DOCUMENT_PATH = "/desktop";
|
||||
|
||||
type DesktopDocumentLocation = Pick<Location, "pathname" | "search">;
|
||||
|
||||
export type DesktopDocumentOptions = {
|
||||
source: string | null;
|
||||
session: string | null;
|
||||
control: boolean;
|
||||
};
|
||||
|
||||
function desktopDocumentPath(basePath = ""): string {
|
||||
return `${normalizeRouteBasePath(basePath)}${DESKTOP_DOCUMENT_PATH}`;
|
||||
}
|
||||
|
||||
export function isDesktopDocumentPath(pathname: string, basePath: string): boolean {
|
||||
return normalizeRoutePath(pathname) === desktopDocumentPath(basePath);
|
||||
}
|
||||
|
||||
export function isDesktopOnlyView(
|
||||
location: DesktopDocumentLocation | undefined = globalThis.location,
|
||||
basePath = "",
|
||||
): boolean {
|
||||
return (
|
||||
new URLSearchParams(location?.search ?? "").get("view") === "desktop" ||
|
||||
isDesktopDocumentPath(location?.pathname ?? "/", basePath)
|
||||
);
|
||||
}
|
||||
|
||||
export function desktopDocumentOptions(
|
||||
location: Pick<DesktopDocumentLocation, "search"> | undefined = globalThis.location,
|
||||
): DesktopDocumentOptions {
|
||||
const search = new URLSearchParams(location?.search ?? "");
|
||||
return {
|
||||
source: search.get("source"),
|
||||
session: search.get("session"),
|
||||
control: search.get("control") === "1",
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { buildControlUiFocusPath } from "@openclaw/session-url-contract";
|
||||
// Control UI startup settings resolve native auth handoff and URL parameters.
|
||||
import {
|
||||
CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM,
|
||||
@@ -38,6 +39,27 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLegacyTerminalViewLocation(
|
||||
location: ApplicationStartupLocation,
|
||||
basePath: string,
|
||||
): ApplicationStartupLocation {
|
||||
const applicationRoot = basePath ? `${basePath}/` : "/";
|
||||
if (location.pathname !== applicationRoot) {
|
||||
return location;
|
||||
}
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
if (searchParams.get("view") !== "terminal") {
|
||||
return location;
|
||||
}
|
||||
searchParams.delete("view");
|
||||
const search = searchParams.toString();
|
||||
return {
|
||||
pathname: buildControlUiFocusPath({ kind: "terminal" }, basePath),
|
||||
search: search ? `?${search}` : "",
|
||||
hash: location.hash,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveApplicationStartupSettings(
|
||||
initialSettings: UiSettings,
|
||||
location: ApplicationStartupLocation,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTerminalOnlyView, terminalDocumentPath } from "./terminal-document-mode.ts";
|
||||
|
||||
describe("terminal document mode", () => {
|
||||
it.each([
|
||||
["the root route", { pathname: "/terminal", search: "" }, ""],
|
||||
["a base-mounted route", { pathname: "/openclaw/terminal", search: "" }, "/openclaw"],
|
||||
["the embedded query form", { pathname: "/", search: "?view=terminal" }, ""],
|
||||
])("recognizes %s", (_label, location, basePath) => {
|
||||
expect(isTerminalOnlyView(location, basePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat an ordinary route as a terminal document", () => {
|
||||
expect(isTerminalOnlyView({ pathname: "/chat", search: "" }, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a base-path-aware user-facing route", () => {
|
||||
expect(terminalDocumentPath("/openclaw/")).toBe("/openclaw/terminal");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter";
|
||||
|
||||
const TERMINAL_DOCUMENT_PATH = "/terminal";
|
||||
|
||||
type TerminalDocumentLocation = Pick<Location, "pathname" | "search">;
|
||||
|
||||
export function terminalDocumentPath(basePath = ""): string {
|
||||
return `${normalizeRouteBasePath(basePath)}${TERMINAL_DOCUMENT_PATH}`;
|
||||
}
|
||||
|
||||
export function isTerminalDocumentPath(pathname: string, basePath: string): boolean {
|
||||
return normalizeRoutePath(pathname) === terminalDocumentPath(basePath);
|
||||
}
|
||||
|
||||
export function isTerminalOnlyView(
|
||||
location: TerminalDocumentLocation | undefined = globalThis.location,
|
||||
basePath = "",
|
||||
): boolean {
|
||||
return (
|
||||
new URLSearchParams(location?.search ?? "").get("view") === "terminal" ||
|
||||
isTerminalDocumentPath(location?.pathname ?? "/", basePath)
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export async function resolveDesktopDocumentInventoryTarget(options: {
|
||||
} catch {}
|
||||
}
|
||||
const requestedSource = resolveDesktopDocumentTarget(
|
||||
{ source: options.source, session: options.sessionKey, control: false },
|
||||
{ source: options.source, session: options.sessionKey },
|
||||
session,
|
||||
);
|
||||
return requestedSource !== null &&
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { buildControlUiFocusPath } from "@openclaw/session-url-contract";
|
||||
import { openExternalUrlSafe } from "../../lib/open-external-url.ts";
|
||||
|
||||
export function desktopFocusPath(
|
||||
basePath: string,
|
||||
source?: string | null,
|
||||
control = false,
|
||||
): string {
|
||||
return buildControlUiFocusPath({ kind: "desktop", source, control }, basePath);
|
||||
}
|
||||
|
||||
export function openDesktopFocus(basePath: string, source?: string | null, control = false): void {
|
||||
openExternalUrlSafe(desktopFocusPath(basePath, source, control));
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export function renderDesktopPanelHeader(options: {
|
||||
fullscreenControl: TemplateResult;
|
||||
onClose: () => void;
|
||||
onDock: (dock: "bottom" | "right") => void;
|
||||
onOpenWindow: () => void;
|
||||
}) {
|
||||
return html`
|
||||
<header class="rail-header bp-header">
|
||||
@@ -34,6 +35,15 @@ export function renderDesktopPanelHeader(options: {
|
||||
>
|
||||
${icons.panelRightOpen}
|
||||
</button>
|
||||
<button
|
||||
class="rail-header__action bp-icon bp-open-window"
|
||||
type="button"
|
||||
title=${t("desktop.openWindow")}
|
||||
aria-label=${t("desktop.openWindow")}
|
||||
@click=${options.onOpenWindow}
|
||||
>
|
||||
${icons.externalLink}
|
||||
</button>
|
||||
${options.fullscreenControl}
|
||||
<button
|
||||
class="rail-header__action bp-icon"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { DesktopClient, type DesktopConnectionHandle } from "./desktop-client.ts";
|
||||
import { resolveDesktopDocumentInventoryTarget } from "./desktop-document-inventory.ts";
|
||||
import { renderDesktopDocumentView } from "./desktop-document-view.ts";
|
||||
import { openDesktopFocus } from "./desktop-focus-window.ts";
|
||||
import { DesktopMobileKeyboard } from "./desktop-mobile-keyboard.ts";
|
||||
import type {
|
||||
DesktopAppId,
|
||||
@@ -49,6 +50,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
@property({ attribute: false }) documentSource: string | null = null;
|
||||
@property({ attribute: false }) documentSession: string | null = null;
|
||||
@property({ type: Boolean }) documentControl = false;
|
||||
@property({ attribute: false }) basePath = "";
|
||||
/** Hosted by the chat side panel, which owns visibility and geometry. */
|
||||
@property({ type: Boolean }) embedded = false;
|
||||
/** This embedded instance is the active pane's visible Desktop presenter. */
|
||||
@@ -685,6 +687,8 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
dock,
|
||||
fullscreenControl: this.fullscreenMode.renderButton(),
|
||||
onDock: (nextDock) => this.dockLayout.setDock(nextDock),
|
||||
onOpenWindow: () =>
|
||||
openDesktopFocus(this.basePath, this.environmentId, this.controlling),
|
||||
onClose: () => this.closePanel(),
|
||||
})}
|
||||
<div class="desktop-content">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DesktopSource, EnvironmentSummary } from "@openclaw/gateway-protocol";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { DesktopDocumentOptions } from "../../app/desktop-document-mode.ts";
|
||||
import { resolveChatPaneDesktopTarget } from "../../pages/chat/chat-pane-placement.ts";
|
||||
|
||||
export function desktopSourceForEnvironment(
|
||||
@@ -20,7 +19,7 @@ export function desktopSourceForEnvironment(
|
||||
* owner pulls the chat page's dependency tree, which must stay out of the startup chunk.
|
||||
*/
|
||||
export function resolveDesktopDocumentTarget(
|
||||
options: DesktopDocumentOptions,
|
||||
options: { source: string | null; session: string | null },
|
||||
session: GatewaySessionRow | undefined,
|
||||
): string | null {
|
||||
return options.source ?? (options.session ? resolveChatPaneDesktopTarget(session) : null);
|
||||
|
||||
@@ -82,29 +82,29 @@ describe("OpenClawTerminalPanel accessibility", () => {
|
||||
window.removeEventListener(TERMINAL_PANEL_DOCK_BOTTOM_EVENT, event);
|
||||
});
|
||||
|
||||
it("opens the base-mounted full-screen terminal in an isolated tab", async () => {
|
||||
it("opens the base-mounted focused terminal in an isolated tab", async () => {
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const panel = createPanel(createPickerClient());
|
||||
panel.basePath = "/openclaw";
|
||||
await waitForFast(() =>
|
||||
expect(
|
||||
panel.renderRoot.querySelector('[aria-label="Open full-screen terminal"]'),
|
||||
panel.renderRoot.querySelector('[aria-label="Open terminal in new window"]'),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
|
||||
panel.renderRoot
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Open full-screen terminal"]')
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Open terminal in new window"]')
|
||||
?.click();
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/openclaw/terminal",
|
||||
"http://localhost:3000/openclaw/focus/terminal",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
|
||||
panel.fullscreen = true;
|
||||
await panel.updateComplete;
|
||||
expect(panel.renderRoot.querySelector('[aria-label="Open full-screen terminal"]')).toBeNull();
|
||||
expect(panel.renderRoot.querySelector('[aria-label="Open terminal in new window"]')).toBeNull();
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -382,8 +382,8 @@ export function renderTerminalPanelActions(params: {
|
||||
class="rail-header__action tp-icon tp-open-fullscreen"
|
||||
type="button"
|
||||
data-new-tab-action
|
||||
title=${t("terminal.openFullscreen")}
|
||||
aria-label=${t("terminal.openFullscreen")}
|
||||
title=${t("terminal.openWindow")}
|
||||
aria-label=${t("terminal.openWindow")}
|
||||
@click=${params.onOpenFullscreen}
|
||||
>
|
||||
${icons.maximize}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
// session. The browser runtime is dynamically imported on first open so it
|
||||
// never weighs down the initial Control UI bundle.
|
||||
import { initialState, Task, TaskStatus } from "@lit/task";
|
||||
import { buildControlUiFocusPath } from "@openclaw/session-url-contract";
|
||||
import { html, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { terminalDocumentPath } from "../../app/terminal-document-mode.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { openExternalUrlSafe } from "../../lib/open-external-url.ts";
|
||||
import { OpenClawLitElement } from "../../lit/openclaw-element.ts";
|
||||
@@ -72,7 +72,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
|
||||
/** Configured Control UI mount prefix used by document links. */
|
||||
@property({ attribute: false }) basePath = "";
|
||||
/**
|
||||
* Terminal-only document mode (`/terminal` or `?view=terminal`): fills the
|
||||
* Focused terminal document mode (`/focus/terminal`): fills the
|
||||
* viewport, stays open while available, and omits dock chrome.
|
||||
*/
|
||||
@property({ type: Boolean }) fullscreen = false;
|
||||
@@ -369,7 +369,10 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
|
||||
}
|
||||
|
||||
private openFullscreen(): void {
|
||||
openExternalUrlSafe(terminalDocumentPath(this.basePath));
|
||||
const focusPath = buildControlUiFocusPath({ kind: "terminal" }, this.basePath);
|
||||
if (focusPath) {
|
||||
openExternalUrlSafe(focusPath);
|
||||
}
|
||||
}
|
||||
|
||||
resetTerminalSessionPicker(): void {
|
||||
|
||||
@@ -12,7 +12,16 @@ const suite = createControlUiE2eSuite({
|
||||
startServerBeforeBrowser: true,
|
||||
});
|
||||
|
||||
const sessionKey = "agent:main:dashboard";
|
||||
const sessionKey = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef";
|
||||
const initialFocusPath = "focus/dashboard/main/12345678";
|
||||
const canonicalFocusPath = "/focus/dashboard/main/deploy-monitor-12345678";
|
||||
const sessionRow = {
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
boardFace: "dashboard",
|
||||
displayName: "Deploy monitor",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const boardSnapshot = {
|
||||
sessionKey,
|
||||
revision: 1,
|
||||
@@ -67,16 +76,40 @@ async function rememberMainTab(page: Page): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function openFocusFromDashboards(page: Page, focusPath: string): Promise<void> {
|
||||
await page.goto(`${suite.server.baseUrl}dashboards`);
|
||||
await page.locator("openclaw-app-shell").waitFor();
|
||||
await page.goto(`${suite.server.baseUrl}${focusPath}`);
|
||||
}
|
||||
|
||||
async function closeFocusedView(page: Page, label: "Back" | "Close dashboard"): Promise<void> {
|
||||
const action = page.getByRole("button", { name: label, exact: true });
|
||||
await action.waitFor();
|
||||
await action.click();
|
||||
await page.waitForURL(`${suite.server.baseUrl}dashboards`);
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("fails an unsupported focus target visibly without mounting the application shell", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
await installMockGateway(page);
|
||||
await openFocusFromDashboards(page, "focus/not-supported");
|
||||
await page.getByRole("alert").getByText("This focused view is not supported.").waitFor();
|
||||
expect(await page.locator("openclaw-app-shell").count()).toBe(0);
|
||||
await closeFocusedView(page, "Back");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a live interactive board in the shell-free dashboard document", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
sessionKey,
|
||||
deferredMethods: ["sessions.resolve"],
|
||||
featureCapabilities: [GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC],
|
||||
featureMethods: ["board.get", "board.update", "board.widget.grant", "board.widget.put"],
|
||||
methodResponses: {
|
||||
"sessions.describe": {
|
||||
session: { key: sessionKey, kind: "direct", updatedAt: 1 },
|
||||
session: sessionRow,
|
||||
},
|
||||
"board.get": boardSnapshot,
|
||||
"board.widget.grant": {
|
||||
@@ -89,9 +122,10 @@ suite.define(() => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`${suite.server.baseUrl}?view=dashboard&session=${encodeURIComponent(sessionKey)}`,
|
||||
);
|
||||
await page.goto(`${suite.server.baseUrl}${initialFocusPath}`);
|
||||
await gateway.waitForRequest("sessions.resolve");
|
||||
expect(await gateway.getRequests("board.get")).toHaveLength(0);
|
||||
await gateway.resolveDeferred("sessions.resolve", { ok: true, key: sessionKey });
|
||||
const document = page.locator("openclaw-board-document");
|
||||
await document.locator("openclaw-board-view").waitFor();
|
||||
|
||||
@@ -103,7 +137,7 @@ suite.define(() => {
|
||||
await widget.waitFor();
|
||||
expect(await widget.getAttribute("aria-label")).toContain("Dashboard widget: Status.");
|
||||
await document.getByRole("button", { name: "Close dashboard" }).waitFor();
|
||||
expect(new URL(page.url()).searchParams.get("session")).toBe(sessionKey);
|
||||
expect(new URL(page.url()).pathname).toBe(canonicalFocusPath);
|
||||
|
||||
await document
|
||||
.locator('[data-widget-name="permissions"]')
|
||||
@@ -117,11 +151,6 @@ suite.define(() => {
|
||||
revision: 1,
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await widget.waitFor();
|
||||
expect(await widget.getAttribute("aria-label")).toContain("Dashboard widget: Status.");
|
||||
expect(new URL(page.url()).searchParams.get("session")).toBe(sessionKey);
|
||||
|
||||
await gateway.setMethodResponse("board.get", {
|
||||
...boardSnapshot,
|
||||
revision: 2,
|
||||
@@ -189,18 +218,77 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a clear outcome when the requested session does not exist", async () => {
|
||||
it("keeps ambiguity candidates inside the focused dashboard namespace", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
await installMockGateway(page, {
|
||||
const secondKey = "agent:main:dashboard:12345678-aaaa-cdef-1234-567890abcdef";
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["board.get"],
|
||||
methodResponses: { "sessions.describe": { session: null } },
|
||||
methodResponses: {
|
||||
"sessions.resolve": {
|
||||
ok: false,
|
||||
candidates: [{ key: sessionKey }, { key: secondKey }],
|
||||
},
|
||||
"sessions.describe": {
|
||||
sequence: [
|
||||
{ session: sessionRow },
|
||||
{
|
||||
session: {
|
||||
...sessionRow,
|
||||
key: secondKey,
|
||||
displayName: "Deploy monitor beta",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`${suite.server.baseUrl}?view=dashboard&session=${encodeURIComponent(sessionKey)}`,
|
||||
);
|
||||
await openFocusFromDashboards(page, initialFocusPath);
|
||||
const links = page.getByRole("link");
|
||||
await expect.poll(() => links.count()).toBe(2);
|
||||
for (const link of await links.all()) {
|
||||
expect(new URL((await link.getAttribute("href")) ?? "", page.url()).pathname).toMatch(
|
||||
/^\/focus\/dashboard\/main\//u,
|
||||
);
|
||||
}
|
||||
expect(await gateway.getRequests("board.get")).toHaveLength(0);
|
||||
expect(await page.locator("openclaw-board-document").count()).toBe(0);
|
||||
await closeFocusedView(page, "Close dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a clear outcome when the requested session does not exist", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["board.get"],
|
||||
methodResponses: { "sessions.resolve": { ok: false } },
|
||||
});
|
||||
|
||||
await openFocusFromDashboards(page, initialFocusPath);
|
||||
await page.getByText("This session could not be found.", { exact: true }).waitFor();
|
||||
expect(await page.locator("openclaw-app-shell").count()).toBe(0);
|
||||
expect(await page.locator("openclaw-board-document").count()).toBe(0);
|
||||
expect(await gateway.getRequests("board.get")).toHaveLength(0);
|
||||
await closeFocusedView(page, "Close dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes a focused dashboard route-resolution failure", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.resolve": {
|
||||
__mockError: { code: "UNAVAILABLE", message: "session routing is unavailable" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await openFocusFromDashboards(page, initialFocusPath);
|
||||
const alert = page.getByRole("alert");
|
||||
await alert.waitFor();
|
||||
await expect.poll(() => alert.textContent()).toContain("session routing is unavailable");
|
||||
expect(await page.locator("openclaw-app-shell").count()).toBe(0);
|
||||
await closeFocusedView(page, "Close dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,8 +298,9 @@ suite.define(() => {
|
||||
sessionKey,
|
||||
featureMethods: ["board.get"],
|
||||
methodResponses: {
|
||||
"sessions.resolve": { ok: true, key: sessionKey },
|
||||
"sessions.describe": {
|
||||
session: { key: sessionKey, kind: "direct", updatedAt: 1 },
|
||||
session: sessionRow,
|
||||
},
|
||||
"board.get": {
|
||||
__mockError: { code: "UNAVAILABLE", message: "dashboard storage is unavailable" },
|
||||
@@ -219,14 +308,13 @@ suite.define(() => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`${suite.server.baseUrl}?view=dashboard&session=${encodeURIComponent(sessionKey)}`,
|
||||
);
|
||||
await openFocusFromDashboards(page, initialFocusPath);
|
||||
await gateway.waitForRequest("board.get");
|
||||
const alert = page.getByRole("alert");
|
||||
await alert.waitFor();
|
||||
await expect.poll(() => alert.textContent()).toContain("dashboard storage is unavailable");
|
||||
await expect.poll(() => alert.textContent()).toContain("try again");
|
||||
await closeFocusedView(page, "Close dashboard");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,12 +122,26 @@ async function openDesktopDocument(
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it.each([
|
||||
["the query route", "?view=desktop"],
|
||||
["the path route", "desktop"],
|
||||
])("renders a full-bleed shell-free picker from %s", async (_label, route) => {
|
||||
it("returns from an unavailable focused desktop", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { panel } = await openDesktopDocument(page, route, [gatewayEnvironment]);
|
||||
await installMockGateway(page);
|
||||
await page.goto(`${suite.server.baseUrl}dashboards`);
|
||||
await page.locator("openclaw-app-shell").waitFor();
|
||||
await page.goto(`${suite.server.baseUrl}focus/desktop`);
|
||||
|
||||
await page
|
||||
.getByText("Desktop viewing is unavailable for this connection.", { exact: true })
|
||||
.waitFor();
|
||||
const back = page.getByRole("button", { name: "Back", exact: true });
|
||||
await back.waitFor();
|
||||
await back.click();
|
||||
await page.waitForURL(`${suite.server.baseUrl}dashboards`);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a full-bleed shell-free picker", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { panel } = await openDesktopDocument(page, "focus/desktop", [gatewayEnvironment]);
|
||||
const viewer = panel.locator("section.desktop-document");
|
||||
await viewer.waitFor();
|
||||
await panel.getByText("Desktop sources", { exact: true }).waitFor();
|
||||
@@ -141,13 +155,11 @@ suite.define(() => {
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
|
||||
if (route === "?view=desktop") {
|
||||
await mkdir(artifactDirectory, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDirectory, "picker-390x844.png"),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
await mkdir(artifactDirectory, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDirectory, "picker-390x844.png"),
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,7 +167,7 @@ suite.define(() => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await openDesktopDocument(
|
||||
page,
|
||||
"?view=desktop&source=missing-machine",
|
||||
"focus/desktop/source/missing-machine",
|
||||
[gatewayEnvironment],
|
||||
);
|
||||
|
||||
@@ -174,7 +186,7 @@ suite.define(() => {
|
||||
const sessionKey = "agent:main:mobile-session";
|
||||
const { gateway, panel } = await openDesktopDocument(
|
||||
page,
|
||||
`?view=desktop&session=${encodeURIComponent(sessionKey)}`,
|
||||
`focus/desktop/session/${encodeURIComponent(sessionKey)}`,
|
||||
[
|
||||
gatewayEnvironment,
|
||||
{
|
||||
@@ -207,29 +219,17 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lets an explicit source win over the session machine", async () => {
|
||||
it("uses an explicit source without resolving a session", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const sessionKey = "agent:main:mobile-session";
|
||||
const { gateway } = await openDesktopDocument(
|
||||
page,
|
||||
`?view=desktop&source=gateway&session=${encodeURIComponent(sessionKey)}`,
|
||||
[
|
||||
gatewayEnvironment,
|
||||
{
|
||||
id: "node:workstation",
|
||||
type: "node",
|
||||
status: "available",
|
||||
desktop: true,
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
const { gateway } = await openDesktopDocument(page, "focus/desktop/source/gateway", [
|
||||
gatewayEnvironment,
|
||||
{
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
execNode: "workstation",
|
||||
id: "node:workstation",
|
||||
type: "node",
|
||||
status: "available",
|
||||
desktop: true,
|
||||
},
|
||||
);
|
||||
]);
|
||||
|
||||
const request = await gateway.waitForRequest("desktop.observe");
|
||||
expect(request.params).toEqual({ source: { kind: "host" }, control: false });
|
||||
@@ -241,7 +241,7 @@ suite.define(() => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await openDesktopDocument(
|
||||
page,
|
||||
"?view=desktop&session=agent%3Amain%3Amissing",
|
||||
"focus/desktop/session/agent%3Amain%3Amissing",
|
||||
[gatewayEnvironment],
|
||||
undefined,
|
||||
null,
|
||||
@@ -264,7 +264,7 @@ suite.define(() => {
|
||||
|
||||
it("renders inventory failure recovery and retries the preselected source", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await startDesktopDocument(page, "?view=desktop&source=gateway");
|
||||
const { gateway, panel } = await startDesktopDocument(page, "focus/desktop/source/gateway");
|
||||
await gateway.rejectDeferred("environments.list", {
|
||||
code: "UNAVAILABLE",
|
||||
message: "desktop inventory is temporarily unavailable",
|
||||
@@ -296,7 +296,7 @@ suite.define(() => {
|
||||
};
|
||||
const { gateway, panel } = await startDesktopDocument(
|
||||
page,
|
||||
`?view=desktop&session=${encodeURIComponent(sessionKey)}`,
|
||||
`focus/desktop/session/${encodeURIComponent(sessionKey)}`,
|
||||
undefined,
|
||||
{ key: sessionKey, kind: "direct", updatedAt: 1, execNode: "workstation" },
|
||||
);
|
||||
@@ -328,7 +328,7 @@ suite.define(() => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await openDesktopDocument(
|
||||
page,
|
||||
"?view=desktop&source=gateway",
|
||||
"focus/desktop/source/gateway",
|
||||
[gatewayEnvironment],
|
||||
{
|
||||
sequence: [
|
||||
@@ -391,23 +391,18 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies only control=1 as the initial control request", async () => {
|
||||
for (const [value, expected] of [
|
||||
["1", true],
|
||||
["true", false],
|
||||
it("applies the optional control segment as the initial control request", async () => {
|
||||
for (const [route, expected] of [
|
||||
["focus/desktop/source/gateway", false],
|
||||
["focus/desktop/control/source/gateway", true],
|
||||
] as const) {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway } = await openDesktopDocument(
|
||||
page,
|
||||
`?view=desktop&source=gateway&control=${value}`,
|
||||
[gatewayEnvironment],
|
||||
{
|
||||
transport: "rfb",
|
||||
wsPath: `/desktop/observe?token=control-${value}`,
|
||||
expiresAtMs: 60_000,
|
||||
control: expected,
|
||||
},
|
||||
);
|
||||
const { gateway } = await openDesktopDocument(page, route, [gatewayEnvironment], {
|
||||
transport: "rfb",
|
||||
wsPath: `/desktop/observe?token=control-${String(expected)}`,
|
||||
expiresAtMs: 60_000,
|
||||
control: expected,
|
||||
});
|
||||
const request = await gateway.waitForRequest("desktop.observe");
|
||||
expect(request.params).toEqual({ source: { kind: "host" }, control: expected });
|
||||
});
|
||||
|
||||
@@ -289,6 +289,25 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the selected desktop source in a focused window", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
await installMockGateway(page, {
|
||||
featureMethods: ["environments.list", "desktop.observe"],
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsList("local"),
|
||||
"environments.list": { environments: [] },
|
||||
},
|
||||
});
|
||||
await openDesktopPanel(page);
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.getByRole("link", { name: "Open desktop in new window", exact: true }).click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState("domcontentloaded");
|
||||
expect(new URL(popup.url()).pathname).toBe("/focus/desktop");
|
||||
await popup.close();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a right-docked desktop above bottom-docked panels", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
await installMockGateway(page, {
|
||||
|
||||
@@ -241,9 +241,6 @@ suite.define(() => {
|
||||
);
|
||||
expect(startupError).toContain("send outcome unknown");
|
||||
await waitForCommittedChatRoute(page);
|
||||
const startupErrorAlert = page.locator(".chat-cloud-startup-error");
|
||||
await startupErrorAlert.waitFor({ state: "visible" });
|
||||
expect(await startupErrorAlert.textContent()).toContain("send outcome unknown");
|
||||
await gateway.setMethodResponse("sessions.send", {
|
||||
runId: "run-reload-recovery",
|
||||
status: "started",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { errors, type Locator, type Page } from "playwright";
|
||||
import { expect } from "vitest";
|
||||
import {
|
||||
controlUiSessionPath,
|
||||
@@ -90,9 +90,19 @@ export const SESSION_LIST_DEFAULTS = {
|
||||
type LocatorTextPoll = ReturnType<typeof expect.poll<Promise<string | null>>>;
|
||||
|
||||
export function pollLocatorText(locator: Locator): LocatorTextPoll {
|
||||
return expect.poll(() => locator.textContent({ timeout: LOCATOR_TEXT_READ_TIMEOUT_MS }), {
|
||||
timeout: LOCATOR_TEXT_POLL_TIMEOUT_MS,
|
||||
});
|
||||
return expect.poll(
|
||||
async () => {
|
||||
try {
|
||||
return await locator.textContent({ timeout: LOCATOR_TEXT_READ_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
if (error instanceof errors.TimeoutError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ timeout: LOCATOR_TEXT_POLL_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
export function createNewSessionPageE2eSuite() {
|
||||
|
||||
@@ -16,6 +16,27 @@ const deadSessionScreenshotPath = process.env.OPENCLAW_TERMINAL_DEAD_SESSION_SCR
|
||||
const deadSessionVideoDir = process.env.OPENCLAW_TERMINAL_DEAD_SESSION_VIDEO_DIR?.trim();
|
||||
|
||||
suite.define(() => {
|
||||
it("returns from an unavailable focused terminal", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
await installMockGateway(page);
|
||||
await page.goto(`${suite.server.baseUrl}dashboards`);
|
||||
await page.locator("openclaw-app-shell").waitFor();
|
||||
await page.goto(`${suite.server.baseUrl}?view=terminal`);
|
||||
|
||||
await page.waitForURL(`${suite.server.baseUrl}focus/terminal`);
|
||||
expect(await page.locator("openclaw-app-shell").count()).toBe(0);
|
||||
expect(await page.evaluate(() => document.fullscreenElement)).toBeNull();
|
||||
|
||||
await page
|
||||
.getByText("The terminal is not available on this gateway.", { exact: true })
|
||||
.waitFor();
|
||||
const back = page.getByRole("button", { name: "Back", exact: true });
|
||||
await back.waitFor();
|
||||
await back.click();
|
||||
await page.waitForURL(`${suite.server.baseUrl}dashboards`);
|
||||
});
|
||||
});
|
||||
|
||||
it("fences an SW-less stale build before it can restore an ownerless terminal", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const gatewayUrl = suite.server.baseUrl.replace(/^http/u, "ws");
|
||||
@@ -223,7 +244,7 @@ suite.define(() => {
|
||||
terminalEnabled: true,
|
||||
});
|
||||
|
||||
const response = await page.goto(`${suite.server.baseUrl}?view=terminal`);
|
||||
const response = await page.goto(`${suite.server.baseUrl}focus/terminal`);
|
||||
expect(response?.status()).toBe(200);
|
||||
const connect = await gateway.waitForRequest("connect");
|
||||
|
||||
@@ -350,7 +371,7 @@ suite.define(() => {
|
||||
terminalEnabled: true,
|
||||
});
|
||||
|
||||
const response = await page.goto(`${suite.server.baseUrl}?view=terminal`);
|
||||
const response = await page.goto(`${suite.server.baseUrl}focus/terminal`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await gateway.waitForRequest("connect");
|
||||
await gateway.resolveDeferred("connect");
|
||||
|
||||
@@ -54,7 +54,7 @@ suite.define(() => {
|
||||
terminalEnabled: true,
|
||||
});
|
||||
|
||||
await page.goto(`${suite.server.baseUrl}?view=terminal`);
|
||||
await page.goto(`${suite.server.baseUrl}focus/terminal`);
|
||||
await gateway.waitForRequest("connect");
|
||||
await gateway.resolveDeferred("connect");
|
||||
await gateway.waitForRequest("terminal.open");
|
||||
|
||||
@@ -101,6 +101,9 @@ export const en: TranslationMap = {
|
||||
logout: "Logout",
|
||||
skipToMainContent: "Skip to main content",
|
||||
},
|
||||
focus: {
|
||||
unsupported: "This focused view is not supported.",
|
||||
},
|
||||
optionCard: {
|
||||
recommended: "Recommended",
|
||||
skip: "Skip for now",
|
||||
@@ -920,7 +923,7 @@ export const en: TranslationMap = {
|
||||
emptyTitle: "No dashboards yet",
|
||||
emptyDescription: "Open a session and switch to the Dashboard face to add it here.",
|
||||
loadError: "Could not load dashboards: {error}",
|
||||
openFullscreen: "Open full-screen dashboard",
|
||||
openFocusMode: "Open dashboard in focus mode",
|
||||
},
|
||||
dashboardDocument: {
|
||||
close: "Close dashboard",
|
||||
@@ -2176,7 +2179,7 @@ export const en: TranslationMap = {
|
||||
title: "Terminal",
|
||||
toggle: "Toggle terminal",
|
||||
open: "Open terminal",
|
||||
openFullscreen: "Open full-screen terminal",
|
||||
openWindow: "Open terminal in new window",
|
||||
hide: "Hide terminal",
|
||||
resize: "Resize terminal panel",
|
||||
newSession: "New terminal session",
|
||||
@@ -2273,6 +2276,7 @@ export const en: TranslationMap = {
|
||||
},
|
||||
desktop: {
|
||||
title: "Desktop",
|
||||
openWindow: "Open desktop in new window",
|
||||
unavailable: "Desktop viewing is unavailable for this connection.",
|
||||
toggle: "Toggle desktop panel",
|
||||
hide: "Hide desktop panel",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { ProgressCard } from "../../../../packages/gateway-protocol/src/schema/progress-card.js";
|
||||
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { ControlUiSessionPullRequest } from "../../../../src/gateway/control-ui-contract.js";
|
||||
import { desktopFocusPath } from "../../components/desktop/desktop-focus-window.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { resolveAssistantAttachmentAuthToken } from "./chat-pane-state.ts";
|
||||
@@ -69,6 +70,7 @@ export function sidebarPanelDefinitions(
|
||||
const terminalAvailable = state?.terminalAvailable === true;
|
||||
const browserAvailable = state?.browserPanelAvailable === true;
|
||||
const desktopAvailable = params?.desktopAvailable === true;
|
||||
const desktopFocusHref = state ? desktopFocusPath(state.basePath) : null;
|
||||
const definePanel = (
|
||||
slot: SidebarSlotId,
|
||||
textKey: SidebarPanelTextKey,
|
||||
@@ -204,7 +206,22 @@ export function sidebarPanelDefinitions(
|
||||
: undefined,
|
||||
),
|
||||
definePanel("tasks", "tasks", icons.listChecks, params?.tasks ?? null),
|
||||
definePanel("desktop", "desktop", icons.monitor, desktop, { available: desktopAvailable }),
|
||||
definePanel("desktop", "desktop", icons.monitor, desktop, {
|
||||
available: desktopAvailable,
|
||||
...(desktopFocusHref
|
||||
? {
|
||||
headerAction: html`<a
|
||||
class="rail-header__action"
|
||||
href=${desktopFocusHref}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label=${t("desktop.openWindow")}
|
||||
title=${t("desktop.openWindow")}
|
||||
>${icons.externalLink}</a
|
||||
>`,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
definePanel("discussion", "discussion", icons.messageSquare, discussion, {
|
||||
available: discussion !== null,
|
||||
...(params?.discussionOpenUrl
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
|
||||
export type SessionRouteContext = Pick<
|
||||
ApplicationContext,
|
||||
"agents" | "agentSelection" | "basePath" | "gateway" | "sessions"
|
||||
>;
|
||||
@@ -2,7 +2,6 @@ import { controlUiSessionSlug, SESSION_UUID_SUFFIX_RE } from "@openclaw/session-
|
||||
import type { RouteLocation } from "@openclaw/uirouter";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { SessionPathTarget } from "../../app-session-route-paths.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import {
|
||||
consumeSessionNavigationHandoff,
|
||||
prepareSessionNavigationHandoff,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
SESSION_NAVIGATION_KEY_PARAM,
|
||||
} from "../../lib/sessions/route-navigation.ts";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
|
||||
import type { SessionRouteContext as ApplicationContext } from "./route-loader-context.ts";
|
||||
|
||||
export function sessionKeyUuid(sessionKey: string): string | null {
|
||||
const uuid = parseAgentSessionKey(sessionKey)?.rest.match(SESSION_UUID_SUFFIX_RE)?.[1];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { controlUiSessionSlug } from "@openclaw/session-url-contract";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { SessionPathTarget } from "../../app-session-route-paths.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type { SessionRouteContext as ApplicationContext } from "./route-loader-context.ts";
|
||||
import { sessionKeyUuid } from "./route-loader-short-cache.ts";
|
||||
|
||||
const SESSION_REF_SEARCH_LIMIT = 20;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { ErrorCodes } from "@openclaw/gateway-client/browser";
|
||||
import { GatewayRequestError } from "../../api/gateway.ts";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { SessionPathTarget } from "../../app-session-route-paths.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { waitForGatewayClient } from "../../app/gateway-readiness.ts";
|
||||
import type { SessionRouteContext as ApplicationContext } from "./route-loader-context.ts";
|
||||
import {
|
||||
resolveShortSessionReferenceWithListFallback,
|
||||
type ShortSessionListFallbackResolution,
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
|
||||
import { INTERNAL_SESSION_PATH_PARAM } from "../../app-route-paths.ts";
|
||||
import { pathForSession } from "../../app-session-path-builder.ts";
|
||||
import { sessionRefFromPath, type SessionPathTarget } from "../../app-session-route-paths.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { waitForGatewayClient } from "../../app/gateway-readiness.ts";
|
||||
import type { BoardFace } from "../../lib/board/settings.ts";
|
||||
import {
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
resolveUiGlobalAliasAgentId,
|
||||
} from "../../lib/sessions/session-key.ts";
|
||||
import { draftRouteDataFromLocation, draftSearchFromLocation } from "./route-draft.ts";
|
||||
import type { SessionRouteContext as ApplicationContext } from "./route-loader-context.ts";
|
||||
import { findCachedShortSession, sessionKeyUuid } from "./route-loader-short-cache.ts";
|
||||
import {
|
||||
resolveShortSessionReference,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { SessionsListResult } from "../../api/types.ts";
|
||||
import { renderDashboards, type DashboardsRouteData } from "./view.ts";
|
||||
|
||||
function routeData(sessions: SessionsListResult["sessions"]): DashboardsRouteData {
|
||||
function routeData(sessions: SessionsListResult["sessions"], basePath = ""): DashboardsRouteData {
|
||||
return {
|
||||
result: {
|
||||
ts: 1,
|
||||
@@ -15,41 +15,48 @@ function routeData(sessions: SessionsListResult["sessions"]): DashboardsRouteDat
|
||||
sessions,
|
||||
},
|
||||
error: null,
|
||||
basePath: "",
|
||||
basePath,
|
||||
fallbackAgentId: "main",
|
||||
mainKey: "main",
|
||||
};
|
||||
}
|
||||
|
||||
describe("dashboards index", () => {
|
||||
it("links each row through the dashboard session namespace", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderDashboards(
|
||||
routeData([
|
||||
{
|
||||
key: "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
|
||||
kind: "direct",
|
||||
boardFace: "dashboard",
|
||||
displayName: "Deploy monitor",
|
||||
updatedAt: 2,
|
||||
},
|
||||
]),
|
||||
),
|
||||
container,
|
||||
);
|
||||
it.each(["", "/openclaw"])(
|
||||
"links each row through the dashboard session namespace at %s",
|
||||
(basePath) => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderDashboards(
|
||||
routeData(
|
||||
[
|
||||
{
|
||||
key: "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
|
||||
kind: "direct",
|
||||
boardFace: "dashboard",
|
||||
displayName: "Deploy monitor",
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
basePath,
|
||||
),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const row = container.querySelector<HTMLElement>("[data-dashboard-session]");
|
||||
expect(row?.textContent).toContain("Deploy monitor");
|
||||
expect(row?.querySelector<HTMLAnchorElement>(".list-main")?.getAttribute("href")).toBe(
|
||||
"/dashboard/main/deploy-monitor-12345678",
|
||||
);
|
||||
const fullscreen = row?.querySelector<HTMLAnchorElement>("[data-dashboard-fullscreen]");
|
||||
expect(fullscreen?.getAttribute("href")).toBe(
|
||||
"/?view=dashboard&session=agent%3Amain%3Adashboard%3A12345678-90ab-cdef-1234-567890abcdef",
|
||||
);
|
||||
expect(fullscreen?.getAttribute("aria-label")).toBe("Open full-screen dashboard");
|
||||
});
|
||||
const row = container.querySelector<HTMLElement>("[data-dashboard-session]");
|
||||
expect(row?.textContent).toContain("Deploy monitor");
|
||||
expect(row?.querySelector<HTMLAnchorElement>(".list-main")?.getAttribute("href")).toBe(
|
||||
`${basePath}/dashboard/main/deploy-monitor-12345678`,
|
||||
);
|
||||
const fullscreen = row?.querySelector<HTMLAnchorElement>("[data-dashboard-fullscreen]");
|
||||
expect(fullscreen?.getAttribute("href")).toBe(
|
||||
`${basePath}/focus/dashboard/main/deploy-monitor-12345678`,
|
||||
);
|
||||
expect(fullscreen?.hasAttribute("target")).toBe(false);
|
||||
expect(fullscreen?.getAttribute("aria-label")).toBe("Open dashboard in focus mode");
|
||||
},
|
||||
);
|
||||
|
||||
it("explains how to create a dashboard when the list is empty", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { buildControlUiFocusPath } from "@openclaw/session-url-contract";
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import type { SessionsListResult } from "../../api/types.ts";
|
||||
import { titleForRoute } from "../../app-navigation.ts";
|
||||
import { dashboardDocumentHref } from "../../app/dashboard-document-mode.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
@@ -45,6 +45,9 @@ function renderDashboardList(data: DashboardsRouteData) {
|
||||
row,
|
||||
mainKey: data.mainKey,
|
||||
});
|
||||
const focusHref =
|
||||
buildControlUiFocusPath({ kind: "dashboard", path: target.href }, data.basePath) ??
|
||||
target.href;
|
||||
return html`<div class="list-item" data-dashboard-session=${row.key}>
|
||||
<a class="list-main list-item-clickable" href=${target.href}>
|
||||
<span class="list-title">${resolveSessionDisplayName(row.key, row)}</span>
|
||||
@@ -55,10 +58,10 @@ function renderDashboardList(data: DashboardsRouteData) {
|
||||
<a
|
||||
class="btn btn--ghost"
|
||||
data-dashboard-fullscreen=${row.key}
|
||||
href=${dashboardDocumentHref(data.basePath, row.key)}
|
||||
aria-label=${t("dashboardsPage.openFullscreen")}
|
||||
href=${focusHref}
|
||||
aria-label=${t("dashboardsPage.openFocusMode")}
|
||||
>
|
||||
${icons.maximize} ${t("dashboardsPage.openFullscreen")}
|
||||
${icons.maximize} ${t("dashboardsPage.openFocusMode")}
|
||||
</a>
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
Reference in New Issue
Block a user